1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-18 20:59:39 +02:00

Use Redis for ActiveJob and ActionCable (#2004)

* Use Redis for ActiveJob and ActionCable

* Fix alwaysApply setting

* Update queue names and weights

* Tweak weights

* Update job queues

* Update docker setup guide

* Remove deprecated upgrade columns from users table

* Refactor Redis configuration for Sidekiq and caching in production environment

* Add Sidekiq Sentry monitoring

* queue naming fix

* Clean up schema
This commit is contained in:
Zach Gollwitzer 2025-03-19 12:36:16 -04:00 committed by GitHub
parent a7db914005
commit 19cc63c8f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 328 additions and 1684 deletions

View file

@ -1,6 +1,7 @@
---
description: This rule explains the project's tech stack and code conventions
globs: *
description:
globs:
alwaysApply: true
---
This rule serves as high-level documentation for how the Maybe codebase is structured.
@ -19,7 +20,7 @@ This rule serves as high-level documentation for how the Maybe codebase is struc
- TailwindCSS for styles
- Lucide Icons for icons
- Database: PostgreSQL
- Jobs: GoodJob
- Jobs: Sidekiq + Redis
- External
- Payments: Stripe
- User bank data syncing: Plaid

View file

@ -1,20 +1,31 @@
# ================================ PLEASE READ ==========================================
# This file outlines all the possible environment variables supported by the Maybe app.
# ================================ PLEASE READ ===========================================================
# This file outlines all the possible environment variables supported by the Maybe app for self hosting.
#
# This includes several features that are for our "hosted" version of Maybe, which most
# open-source contributors won't need.
#
# If you are developing locally, you should be referencing `.env.local.example` instead.
# =======================================================================================
# If you're a developer setting up your local environment, please use `.env.local.example` instead.
# ========================================================================================================
# Required self-hosting vars
# --------------------------------------------------------------------------------------------------------
# Enables self hosting features (should be set to true unless you know what you're doing)
SELF_HOSTED=true
# Secret key used to encrypt credentials (https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base)
# Has to be a random string, generated eg. by running `openssl rand -hex 64`
SECRET_KEY_BASE=secret-value
# Optional self-hosting vars
# --------------------------------------------------------------------------------------------------------
# Optional: Synth API Key for exchange rates + stock prices
# (you can also set this in your self-hosted settings page)
# Get it here: https://synthfinance.com/
SYNTH_API_KEY=
# Custom port config
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
PORT=3000
# Exchange Rate & Stock Pricing API
# This is used to convert between different currencies in the app. In addition, it fetches global stock prices. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
SYNTH_API_KEY=
# SMTP Configuration
# This is only needed if you intend on sending emails from your Maybe instance (such as for password resets or email financial reports).
# Resend.com is a good option that offers a free tier for sending emails.
@ -37,60 +48,20 @@ POSTGRES_USER=postgres
# This is the domain that your Maybe instance will be hosted at. It is used to generate links in emails and other places.
APP_DOMAIN=
## Error and Performance Monitoring
# The app uses Sentry to monitor errors and performance. In reality, you likely don't need this unless you're deploying Maybe to many users.
SENTRY_DSN=
# If enabled, an invite code generated by `rake invites:create` is required to sign up as a new user.
# This is useful for controlling who can sign up for your Maybe instance.
REQUIRE_INVITE_CODE=false
# Enables self hosting features (should be set to true for most folks)
SELF_HOSTED=true
# The hosting platform used to deploy the app (e.g. "render")
# `localhost` (or unset) is used for local development and testing
HOSTING_PLATFORM=localhost
# Secret key used to encrypt credentials (https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base)
# Has to be a random string, generated eg. by running `openssl rand -hex 64`
SECRET_KEY_BASE=secret-value
# Disable enforcing SSL connections
# DISABLE_SSL=true
# ======================================================================================================
# Upgrades Module - responsible for triggering upgrade alerts, prompts, and auto-upgrade functionality
# ======================================================================================================
#
# UPGRADES_ENABLED: Enables Upgrader class functionality.
# UPGRADES_MODE: Controls how the app will upgrade. `manual` means the user must manually upgrade the app. `auto` means the app will upgrade automatically (great for self-hosting)
# UPGRADES_TARGET: Controls what the app will upgrade to. `release` means the app will upgrade to the latest release. `commit` means the app will upgrade to the latest commit.
#
UPGRADES_ENABLED=false # unless editing the flow, you should keep this `false` locally in development
UPGRADES_MODE=manual # `manual` or `auto`
UPGRADES_TARGET=release # `release` or `commit`
# ======================================================================================================
# Git Repository Module - responsible for fetching latest commit data for upgrades
# ======================================================================================================
#
GITHUB_REPO_OWNER=maybe-finance
GITHUB_REPO_NAME=maybe
GITHUB_REPO_BRANCH=main
# ======================================================================================================
# Active Storage Configuration - responsible for storing file uploads
# ======================================================================================================
#
# * Defaults to disk storage but you can also use Amazon S3, Google Cloud Storage, or Microsoft Azure Storage.
# * Defaults to disk storage but you can also use Amazon S3 or Cloudflare R2
# * Set the appropriate environment variables to use these services.
# * Ensure libvips is installed on your system for image processing - https://github.com/libvips/libvips
#
# Amazon S3
# ==========
# ACTIVE_STORAGE_SERVICE=amazon
# ACTIVE_STORAGE_SERVICE=amazon <- Enables Amazon S3 storage
# S3_ACCESS_KEY_ID=
# S3_SECRET_ACCESS_KEY=
# S3_REGION= # defaults to `us-east-1` if not set
@ -98,26 +69,9 @@ GITHUB_REPO_BRANCH=main
#
# Cloudflare R2
# =============
# ACTIVE_STORAGE_SERVICE=cloudflare
# ACTIVE_STORAGE_SERVICE=cloudflare <- Enables Cloudflare R2 storage
# CLOUDFLARE_ACCOUNT_ID=
# CLOUDFLARE_ACCESS_KEY_ID=
# CLOUDFLARE_SECRET_ACCESS_KEY=
# CLOUDFLARE_BUCKET=
# ======================================================================================================
# Billing Module - responsible for handling billing
# ======================================================================================================
#
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# ======================================================================================================
# Plaid Configuration
# ======================================================================================================
#
PLAID_CLIENT_ID=
PLAID_SECRET=
PLAID_ENV=
PLAID_EU_CLIENT_ID=
PLAID_EU_SECRET=

2
.gitignore vendored
View file

@ -65,3 +65,5 @@ coverage
# Ignore node related files
node_modules
compose.yml

View file

@ -7,6 +7,7 @@ gem "rails", "~> 7.2.2"
# Drivers
gem "pg", "~> 1.5"
gem "redis", "~> 5.4"
# Deployment
gem "puma", ">= 5.0"
@ -25,13 +26,14 @@ gem "turbo-rails"
gem "hotwire_combobox"
# Background Jobs
gem "good_job"
gem "sidekiq"
# Error logging
gem "vernier"
gem "rack-mini-profiler"
gem "sentry-ruby"
gem "sentry-rails"
gem "sentry-sidekiq"
gem "logtail-rails"
# Active Storage

View file

@ -155,8 +155,6 @@ GEM
rubocop (>= 1)
smart_properties
erubi (1.13.1)
et-orbi (1.2.11)
tzinfo
faker (3.5.1)
i18n (>= 1.8.11, < 2)
faraday (2.12.2)
@ -177,18 +175,8 @@ GEM
ffi (1.17.1-x86_64-darwin)
ffi (1.17.1-x86_64-linux-gnu)
ffi (1.17.1-x86_64-linux-musl)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
good_job (4.9.3)
activejob (>= 6.1.0)
activerecord (>= 6.1.0)
concurrent-ruby (>= 1.3.1)
fugit (>= 1.11.0)
railties (>= 6.1.0)
thor (>= 1.0.0)
hashdiff (1.1.2)
highline (3.1.2)
reline
@ -336,7 +324,6 @@ GEM
public_suffix (6.0.1)
puma (6.6.0)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.12)
rack-mini-profiler (3.3.1)
@ -393,6 +380,10 @@ GEM
rdoc (6.12.0)
psych (>= 4.0.0)
redcarpet (3.6.1)
redis (5.4.0)
redis-client (>= 0.22.0)
redis-client (0.24.0)
connection_pool
regexp_parser (2.10.0)
reline (0.6.0)
io-console (~> 0.5)
@ -458,6 +449,15 @@ GEM
sentry-ruby (5.23.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.23.0)
sentry-ruby (~> 5.23.0)
sidekiq (>= 3.0)
sidekiq (8.0.1)
connection_pool (>= 2.5.0)
json (>= 2.9.0)
logger (>= 1.6.2)
rack (>= 3.1.0)
redis-client (>= 0.23.2)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
@ -544,7 +544,6 @@ DEPENDENCIES
faraday
faraday-multipart
faraday-retry
good_job
hotwire-livereload
hotwire_combobox
i18n-tasks
@ -567,6 +566,7 @@ DEPENDENCIES
rails (~> 7.2.2)
rails-settings-cached
redcarpet
redis (~> 5.4)
rotp (~> 6.3)
rqrcode (~> 2.2)
rubocop-rails-omakase
@ -574,6 +574,8 @@ DEPENDENCIES
selenium-webdriver
sentry-rails
sentry-ruby
sentry-sidekiq
sidekiq
simplecov
stimulus-rails
stripe

View file

@ -1,3 +1,3 @@
web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0
css: bundle exec bin/rails tailwindcss:watch
worker: bundle exec good_job start
worker: bundle exec sidekiq

View file

@ -6,9 +6,6 @@
<b>Get
involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybefinance.com) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
_If you're looking for the previous React codebase, you can find it
at [maybe-finance/maybe-archive](https://github.com/maybe-finance/maybe-archive)._
## Backstory
We spent the better part of 2021/2022 building a personal finance + wealth
@ -29,9 +26,8 @@ and eventually offer a hosted version of the app for a small monthly fee.
There are 3 primary ways to use the Maybe app:
1. Managed (easiest) - _coming soon..._
2. [One-click deploy](docs/hosting/one-click-deploy.md)
3. [Self-host with Docker](docs/hosting/docker.md)
1. Managed (easiest) - we're in alpha and release invites in our Discord
2. [Self-host with Docker](docs/hosting/docker.md)
## Contributing
@ -84,37 +80,10 @@ If you'd like multi-currency support, there are a few extra steps to follow.
### Setup Guides
#### Dev Container (optional)
This is 100% optional and meant for devs who don't want to worry about
installing requirements manually for their platform. You can
follow [this guide](https://code.visualstudio.com/docs/devcontainers/containers)
to learn more about Dev Containers.
If you run into `could not connect to server` errors, you may need to change
your `.env`'s `DB_HOST` environment variable value to `db` to point to the
Postgres container.
#### Mac
Please visit
our [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide).
#### Linux
Please visit
our [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide).
#### Windows
Please visit
our [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide).
### Testing Emails
In development, we use `letter_opener` to automatically open emails in your
browser. When an email sends locally, a new browser tab will open with a
preview.
- [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide)
- [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide)
- [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide)
- Dev containers - visit [this guide](https://code.visualstudio.com/docs/devcontainers/containers) to learn more
## Repo Activity

View file

@ -10,7 +10,7 @@ class PagesController < ApplicationController
end
def changelog
@release_notes = Provider::Github.new.fetch_latest_release_notes
@release_notes = Providers.github.fetch_latest_release_notes
render layout: "settings"
end

View file

@ -9,18 +9,6 @@ class Settings::HostingsController < ApplicationController
end
def update
if hosting_params[:upgrades_setting].present?
mode = hosting_params[:upgrades_setting] == "manual" ? "manual" : "auto"
target = hosting_params[:upgrades_setting] == "commit" ? "commit" : "release"
Setting.upgrades_mode = mode
Setting.upgrades_target = target
end
if hosting_params.key?(:render_deploy_hook)
Setting.render_deploy_hook = hosting_params[:render_deploy_hook]
end
if hosting_params.key?(:require_invite_for_signup)
Setting.require_invite_for_signup = hosting_params[:require_invite_for_signup]
end
@ -46,7 +34,7 @@ class Settings::HostingsController < ApplicationController
private
def hosting_params
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :require_email_confirmation, :synth_api_key)
params.require(:setting).permit(:require_invite_for_signup, :require_email_confirmation, :synth_api_key)
end
def raise_if_not_self_hosted

View file

@ -1,56 +0,0 @@
class UpgradesController < ApplicationController
before_action :verify_upgrades_enabled
def acknowledge
commit_sha = params[:id]
upgrade = Upgrader.find_upgrade(commit_sha)
if upgrade
if upgrade.available?
Current.user.acknowledge_upgrade_prompt(upgrade.commit_sha)
flash[:notice] = t(".upgrade_dismissed")
elsif upgrade.complete?
Current.user.acknowledge_upgrade_alert(upgrade.commit_sha)
flash[:notice] = t(".upgrade_complete_dismiss")
else
flash[:alert] = t(".upgrade_not_available")
end
else
flash[:alert] = t(".upgrade_not_found")
end
redirect_back(fallback_location: root_path)
end
def deploy
commit_sha = params[:id]
upgrade = Upgrader.find_upgrade(commit_sha)
unless upgrade
flash[:alert] = t(".upgrade_not_found")
return redirect_back(fallback_location: root_path)
end
prior_acknowledged_upgrade_commit_sha = Current.user.last_prompted_upgrade_commit_sha
# Optimistically acknowledge the upgrade prompt
Current.user.acknowledge_upgrade_prompt(upgrade.commit_sha)
upgrade_result = Upgrader.upgrade_to(upgrade)
if upgrade_result[:success]
flash[:notice] = upgrade_result[:message]
else
# If the upgrade fails, revert to the prior acknowledged upgrade
Current.user.acknowledge_upgrade_prompt(prior_acknowledged_upgrade_commit_sha)
flash[:alert] = upgrade_result[:message]
end
redirect_back(fallback_location: root_path)
end
private
def verify_upgrades_enabled
head :not_found unless ENV["UPGRADES_ENABLED"] == "true"
end
end

View file

@ -1,14 +0,0 @@
module UpgradesHelper
def get_upgrade_for_notification(user, upgrades_mode)
return nil unless ENV["UPGRADES_ENABLED"] == "true"
return nil unless user.present?
completed_upgrade = Upgrader.completed_upgrade
return completed_upgrade if completed_upgrade && user.last_alerted_upgrade_commit_sha != completed_upgrade.commit_sha
available_upgrade = Upgrader.available_upgrade
if available_upgrade && upgrades_mode == "manual" && user.last_prompted_upgrade_commit_sha != available_upgrade.commit_sha
available_upgrade
end
end
end

View file

@ -1,7 +1,5 @@
class ApplicationJob < ActiveJob::Base
# Automatically retry jobs that encountered a deadlock
# retry_on ActiveRecord::Deadlocked
# Most jobs are safe to ignore if the underlying records are no longer available
# discard_on ActiveJob::DeserializationError
retry_on ActiveRecord::Deadlocked
discard_on ActiveJob::DeserializationError
queue_as :low_priority # default queue
end

View file

@ -1,31 +0,0 @@
class AutoUpgradeJob < ApplicationJob
queue_as :latency_low
def perform(*args)
raise_if_disabled
return Rails.logger.info "Skipping auto-upgrades because app is set to manual upgrades. Please set UPGRADES_MODE=auto to enable auto-upgrades" if Setting.upgrades_mode == "manual"
Rails.logger.info "Searching for available auto-upgrades..."
candidate = Upgrader.available_upgrade_by_type(Setting.upgrades_target)
if candidate
if Rails.cache.read("last_auto_upgrade_commit_sha") == candidate.commit_sha
Rails.logger.info "Skipping auto upgrade: #{candidate.type} #{candidate.commit_sha} deploy in progress"
return
end
Rails.logger.info "Auto upgrading to #{candidate.type} #{candidate.commit_sha}..."
Upgrader.upgrade_to(candidate)
Rails.cache.write("last_auto_upgrade_commit_sha", candidate.commit_sha, expires_in: 1.day)
else
Rails.logger.info "No auto upgrade available at this time"
end
end
private
def raise_if_disabled
raise "Upgrades module is disabled. Please set UPGRADES_ENABLED=true to enable upgrade features" unless ENV["UPGRADES_ENABLED"] == "true"
end
end

View file

@ -1,5 +1,5 @@
class DataCacheClearJob < ApplicationJob
queue_as :default
queue_as :low_priority
def perform(family)
ActiveRecord::Base.transaction do

View file

@ -1,5 +1,5 @@
class DestroyJob < ApplicationJob
queue_as :latency_low
queue_as :low_priority
def perform(model)
model.destroy

View file

@ -1,5 +1,5 @@
class EnrichTransactionBatchJob < ApplicationJob
queue_as :latency_high
queue_as :low_priority
def perform(account, batch_size = 100, offset = 0)
account.enrich_transaction_batch(batch_size, offset)

View file

@ -1,5 +1,5 @@
class FamilyResetJob < ApplicationJob
queue_as :default
queue_as :low_priority
def perform(family)
# Delete all family data except users

View file

@ -1,5 +1,5 @@
class FetchSecurityInfoJob < ApplicationJob
queue_as :latency_low
queue_as :low_priority
def perform(security_id)
return unless Security.provider.present?

View file

@ -1,5 +1,5 @@
class ImportJob < ApplicationJob
queue_as :latency_medium
queue_as :high_priority
def perform(import)
import.publish

View file

@ -1,5 +1,5 @@
class RevertImportJob < ApplicationJob
queue_as :latency_low
queue_as :medium_priority
def perform(import)
import.revert

View file

@ -1,7 +1,8 @@
class SyncJob < ApplicationJob
queue_as :latency_medium
queue_as :high_priority
def perform(sync)
sleep 1 # simulate work for faster jobs
sync.perform
end
end

View file

@ -1,5 +1,5 @@
class UserPurgeJob < ApplicationJob
queue_as :latency_low
queue_as :low_priority
def perform(user)
user.purge

View file

@ -1,43 +1,10 @@
class Provider::Github
attr_reader :name, :owner, :branch
def initialize(config = {})
@name = config[:name] || ENV.fetch("GITHUB_REPO_NAME", "maybe")
@owner = config[:owner] || ENV.fetch("GITHUB_REPO_OWNER", "maybe-finance")
@branch = config[:branch] || ENV.fetch("GITHUB_REPO_BRANCH", "main")
end
def fetch_latest_upgrade_candidates
Rails.cache.fetch("latest_github_upgrade_candidates", expires_in: 30.minutes) do
Rails.logger.info "Fetching latest GitHub upgrade candidates from #{repo} on branch #{branch}..."
begin
latest_release = Octokit.releases(repo).first
latest_version = latest_release ? Semver.from_release_tag(latest_release.tag_name) : Semver.new(Maybe.version)
latest_commit = Octokit.branch(repo, branch)
release_info = if latest_release
{
version: latest_version,
url: latest_release.html_url,
commit_sha: Octokit.commit(repo, latest_release.tag_name).sha
}
end
commit_info = {
version: latest_version,
commit_sha: latest_commit.commit.sha,
url: latest_commit.commit.html_url
}
{
release: release_info,
commit: commit_info
}
rescue => e
Rails.logger.error "Failed to fetch latest GitHub commits: #{e.message}"
nil
end
end
def initialize
@name = "maybe"
@owner = "maybe-finance"
@branch = "main"
end
def fetch_latest_release_notes

View file

@ -2,24 +2,7 @@
class Setting < RailsSettings::Base
cache_prefix { "v1" }
field :render_deploy_hook,
type: :string,
default: ENV["RENDER_DEPLOY_HOOK"],
validates: { allow_blank: true, format: { with: /\Ahttps:\/\/api\.render\.com\/deploy\/srv-.+\z/ } }
field :upgrades_mode,
type: :string,
default: ENV.fetch("UPGRADES_MODE", "manual"),
validates: { inclusion: { in: %w[manual auto] } }
field :upgrades_target,
type: :string,
default: ENV.fetch("UPGRADES_TARGET", "release"),
validates: { inclusion: { in: %w[release commit] } }
field :synth_api_key, type: :string, default: ENV["SYNTH_API_KEY"]
field :require_invite_for_signup, type: :boolean, default: false
field :require_email_confirmation, type: :boolean, default: ENV.fetch("REQUIRE_EMAIL_CONFIRMATION", "true") == "true"
end

View file

@ -1,57 +0,0 @@
class Upgrader
include Provided
class << self
attr_writer :config
def config
@config ||= Config.new
end
def upgrade_to(commit_or_upgrade)
upgrade = commit_or_upgrade.is_a?(String) ? find_upgrade(commit_or_upgrade) : commit_or_upgrade
config.deployer.deploy(upgrade)
end
def find_upgrade(commit)
upgrade_candidates.find { |candidate| candidate.commit_sha == commit }
end
def available_upgrade
available_upgrades.first
end
# Default to showing releases first, then commits
def completed_upgrade
completed_upgrades.find { |upgrade| upgrade.type == "release" } || completed_upgrades.first
end
def available_upgrade_by_type(type)
if type == "commit"
commit_upgrade = available_upgrades.find { |upgrade| upgrade.type == "commit" }
commit_upgrade || available_upgrades.first
elsif type == "release"
available_upgrades.find { |upgrade| upgrade.type == "release" }
end
end
private
def available_upgrades
upgrade_candidates.select(&:available?)
end
def completed_upgrades
upgrade_candidates.select(&:complete?)
end
def upgrade_candidates
latest_candidates = fetch_latest_upgrade_candidates_from_provider
return [] unless latest_candidates
commit_candidate = Upgrade.new("commit", latest_candidates[:commit])
release_candidate = latest_candidates[:release] && Upgrade.new("release", latest_candidates[:release])
[ release_candidate, commit_candidate ].compact.uniq { |candidate| candidate.commit_sha }
end
end
end

View file

@ -1,17 +0,0 @@
class Upgrader::Config
attr_reader :env, :options
def initialize(options = {}, env: ENV)
@env = env
@options = options
end
def deployer
factory = Upgrader::Deployer
factory.for(hosting_platform)
end
def hosting_platform
options[:hosting_platform] || env["HOSTING_PLATFORM"]
end
end

View file

@ -1,12 +0,0 @@
class Upgrader::Deployer
def self.for(platform)
case platform
when nil, "localhost"
Upgrader::Deployer::Null.new
when "render"
Upgrader::Deployer::Render.new
else
raise "Unknown platform: #{platform}"
end
end
end

View file

@ -1,8 +0,0 @@
class Upgrader::Deployer::Null
def deploy(upgrade)
{
success: true,
message: I18n.t("upgrader.deployer.null_deployer.success_message")
}
end
end

View file

@ -1,41 +0,0 @@
class Upgrader::Deployer::Render
def deploy(upgrade)
if Setting.render_deploy_hook.blank?
return {
success: false,
message: I18n.t("upgrader.deployer.render.error_message_not_set"),
troubleshooting_url: "/settings/self_hosting/edit"
}
end
Rails.logger.info I18n.t("upgrader.deployer.render.deploy_log_info", type: upgrade.type, commit_sha: upgrade.commit_sha)
begin
uri = URI.parse(Setting.render_deploy_hook)
uri.query = [ uri.query, "ref=#{upgrade.commit_sha}" ].compact.join("&")
response = Faraday.post(uri.to_s)
unless response.success?
Rails.logger.error I18n.t("upgrader.deployer.render.deploy_log_error", type: upgrade.type, commit_sha: upgrade.commit_sha, error_message: response.body)
return default_error_response
end
{
success: true,
message: I18n.t("upgrader.deployer.render.success_message", commit_sha: upgrade.commit_sha.slice(0, 7))
}
rescue => e
Rails.logger.error I18n.t("upgrader.deployer.render.deploy_log_error", type: upgrade.type, commit_sha: upgrade.commit_sha, error_message: e.message)
default_error_response
end
end
private
def default_error_response
{
success: false,
message: I18n.t("upgrader.deployer.render.error_message_failed_deploy"),
troubleshooting_url: I18n.t("upgrader.deployer.render.troubleshooting_url")
}
end
end

View file

@ -1,10 +0,0 @@
module Upgrader::Provided
extend ActiveSupport::Concern
class_methods do
private
def fetch_latest_upgrade_candidates_from_provider
Providers.github.fetch_latest_upgrade_candidates
end
end
end

View file

@ -1,29 +0,0 @@
class Upgrader::Upgrade
attr_reader :type, :commit_sha, :version, :url
def initialize(type, data)
@type = %w[release commit].include?(type) ? type : raise(ArgumentError, "Type must be either 'release' or 'commit'")
@commit_sha = data[:commit_sha]
@version = normalize_version(data[:version])
@url = data[:url]
end
def complete?
commit_sha == Maybe.commit_sha
end
def available?
return false if commit_sha == Maybe.commit_sha || version < Maybe.version
return false if version == Maybe.version && type == "release"
true
end
def to_s
type == "release" ? version.to_release_tag : "#{commit_sha.first(7)} (pre-release)"
end
private
def normalize_version(version)
version.is_a?(Semver) ? version : Semver.new(version)
end
end

View file

@ -69,22 +69,6 @@ class User < ApplicationRecord
(display_name&.first || email.first).upcase
end
def acknowledge_upgrade_prompt(commit_sha)
update!(last_prompted_upgrade_commit_sha: commit_sha)
end
def acknowledge_upgrade_alert(commit_sha)
update!(last_alerted_upgrade_commit_sha: commit_sha)
end
def has_seen_upgrade_prompt?(upgrade)
last_prompted_upgrade_commit_sha == upgrade.commit_sha
end
def has_seen_upgrade_alert?(upgrade)
last_alerted_upgrade_commit_sha == upgrade.commit_sha
end
# Deactivation
validate :can_deactivate, if: -> { active_changed? && !active }
after_update_commit :purge_later, if: -> { saved_change_to_active?(from: true, to: false) }

View file

@ -4,7 +4,7 @@
<span class="text-white font-semibold uppercase">Super Admin</span>
</div>
<div>
<%= link_to "Jobs", good_job_url, class: "text-white underline hover:text-gray-100" %>
<%= link_to "Jobs", sidekiq_web_url, class: "text-white underline hover:text-gray-100" %>
</div>
<div class="flex items-center space-x-2 px-2 py-2 text-white">

View file

@ -19,10 +19,6 @@
<%= family_notifications_stream %>
<%= family_stream %>
<% if self_hosted? && (upgrade = get_upgrade_for_notification(Current.user, Setting.upgrades_mode)) %>
<%= render partial: "shared/upgrade_notification", locals: { upgrade: upgrade } %>
<% end %>
<%= turbo_frame_tag "modal" %>
<%= turbo_frame_tag "drawer" %>
<%= render "shared/confirm_modal" %>

View file

@ -10,7 +10,3 @@
<%= family_notifications_stream %>
<%= family_stream %>
<% if self_hosted? && (upgrade = get_upgrade_for_notification(Current.user, Setting.upgrades_mode)) %>
<%= render partial: "shared/upgrade_notification", locals: { upgrade: upgrade } %>
<% end %>

View file

@ -1,9 +0,0 @@
<% if ENV["HOSTING_PLATFORM"] == "render" %>
<%= styled_form_with model: Setting.new, url: settings_hosting_path, method: :patch, data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %>
<div class="space-y-4">
<h2 class="font-medium mb-1"><%= t(".title") %></h2>
<p class="text-secondary text-sm mb-4"><%= t(".description") %></p>
<%= form.url_field :render_deploy_hook, label: t(".render_deploy_hook_label"), placeholder: t(".render_deploy_hook_placeholder"), value: Setting.render_deploy_hook, data: { "auto-submit-form-target" => "auto" } %>
</div>
<% end %>
<% end %>

View file

@ -1,41 +0,0 @@
<% if ENV["HOSTING_PLATFORM"] == "render" %>
<div>
<h2 class="font-medium mb-1"><%= t(".title") %></h2>
<p class="text-secondary text-sm mb-4"><%= t(".description") %></p>
<%= styled_form_with model: Setting.new, url: settings_hosting_path, method: :patch, data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %>
<div class="space-y-4">
<div class="flex items-center gap-4">
<%= form.radio_button :upgrades_setting, "manual", checked: Setting.upgrades_mode == "manual", data: { "auto-submit-form-target" => "auto", "autosubmit-trigger-event": "input" } %>
<%= form.label :upgrades_mode_manual, t(".manual_title"), class: "text-primary text-sm" do %>
<span class="font-medium"><%= t(".manual_title") %></span>
<br>
<span class="text-secondary">
<%= t(".manual_description") %>
</span>
<% end %>
</div>
<div class="flex items-center gap-4">
<%= form.radio_button :upgrades_setting, "release", checked: Setting.upgrades_mode == "auto" && Setting.upgrades_target == "release", data: { "auto-submit-form-target" => "auto", "autosubmit-trigger-event": "input" } %>
<%= form.label :upgrades_mode_release, t(".latest_release_title"), class: "text-primary text-sm" do %>
<span class="font-medium"><%= t(".latest_release_title") %></span>
<br>
<span class="text-secondary">
<%= t(".latest_release_description") %>
</span>
<% end %>
</div>
<div class="flex items-center gap-4">
<%= form.radio_button :upgrades_setting, "commit", checked: Setting.upgrades_mode == "auto" && Setting.upgrades_target == "commit", data: { "auto-submit-form-target" => "auto", "autosubmit-trigger-event": "input" } %>
<%= form.label :upgrades_mode_commit, t(".latest_commit_title"), class: "text-primary text-sm" do %>
<span class="font-medium"><%= t(".latest_commit_title") %></span>
<br>
<span class="text-secondary">
<%= t(".latest_commit_description") %>
</span>
<% end %>
</div>
</div>
<% end %>
</div>
<% end %>

View file

@ -2,8 +2,6 @@
<%= settings_section title: t(".general") do %>
<div class="space-y-6">
<%= render "settings/hostings/upgrade_settings" %>
<%= render "settings/hostings/provider_settings" %>
<%= render "settings/hostings/synth_settings" %>
</div>
<% end %>

View file

@ -1,17 +0,0 @@
<%# locals: (upgrade:) %>
<div class="bg-white space-y-4 text-right fixed bottom-10 right-10 p-5 shadow-border-xs rounded-md z-50 max-w-[350px]">
<div>
<p><%= link_to upgrade.to_s, upgrade.url, class: "text-sm text-blue-500 underline hover:text-blue-700", target: "_blank" %></p>
<% if upgrade.complete? %>
<p class="text-primary"><%= t(".app_upgraded", version: upgrade.to_s) %></p>
<% else %>
<p class="text-primary"><%= t(".new_version_available") %></p>
<% end %>
</div>
<div class="flex justify-end items-center gap-2">
<%= button_to t(".dismiss"), acknowledge_upgrade_path(upgrade.commit_sha), method: :post, class: "#{upgrade.complete? ? 'bg-gray-900 text-white' : 'bg-gray-100 text-primary'} text-sm font-bold p-2 rounded-lg" %>
<% if upgrade.available? %>
<%= button_to t(".upgrade_now"), deploy_upgrade_path(upgrade.commit_sha), method: :post, class: "bg-gray-900 hover:bg-gray-700 text-white font-medium text-sm p-2 rounded-lg" %>
<% end %>
</div>
</div>

View file

@ -10,16 +10,3 @@ echo "Precompiling assets..."
./bin/rails assets:clean
echo "Build complete"
# Self Hosters:
#
# By default, one-click deploys are free-tier instances (to avoid unexpected charges)
# Render does NOT allow free-tier instances to use the `preDeployCommand` feature, so
# database migrations must be run in the build step.
#
# If you're on a paid Render plan, you can remove the `RUN_DB_MIGRATIONS_IN_BUILD_STEP` (or set to `false`)
if [ "$RUN_DB_MIGRATIONS_IN_BUILD_STEP" = "true" ]; then
echo "Initiating database migrations for the free tier..."
bundle exec rails db:migrate
echo "Database migrations completed. Reminder: If you've moved to a Render paid plan, you can remove the RUN_DB_MIGRATIONS_IN_BUILD_STEP environment variable to utilize the `preDeployCommand` feature for migrations."
fi

100
compose.example.yml Normal file
View file

@ -0,0 +1,100 @@
# ===========================================================================
# Example Docker Compose file
# ===========================================================================
#
# Purpose:
# --------
#
# This file is an example Docker Compose configuration for self hosting
# Maybe on your local machine or on a cloud VPS.
#
# The configuration below is a "standard" setup that works out of the box,
# but if you're running this outside of a local network, it is recommended
# to set the environment variables for extra security.
#
# Setup:
# ------
#
# To run this, you should read the setup guide:
#
# https://github.com/maybe-finance/maybe/blob/main/docs/hosting/docker.md
#
# Troubleshooting:
# ----------------
#
# If you run into problems, you should open a Discussion here:
#
# https://github.com/maybe-finance/maybe/discussions/categories/general
#
x-db-env: &db_env
POSTGRES_USER: ${POSTGRES_USER:-maybe_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-maybe_password}
POSTGRES_DB: ${POSTGRES_DB:-maybe_production}
x-rails-env: &rails_env
<<: *db_env
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-a7523c3d0ae56415046ad8abae168d71074a79534a7062258f8d1d51ac2f76d3c3bc86d86b6b0b307df30d9a6a90a2066a3fa9e67c5e6f374dbd7dd4e0778e13}
SELF_HOSTED: "true"
RAILS_FORCE_SSL: "false"
RAILS_ASSUME_SSL: "false"
DB_HOST: db
DB_PORT: 5432
REDIS_URL: redis://redis:6379/1
services:
web:
image: ghcr.io/maybe-finance/maybe:latest
volumes:
- app-storage:/rails/storage
ports:
- 3000:3000
restart: unless-stopped
environment:
<<: *rails_env
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
worker:
image: ghcr.io/maybe-finance/maybe:latest
command: bundle exec sidekiq
restart: unless-stopped
depends_on:
redis:
condition: service_healthy
environment:
<<: *rails_env
db:
image: postgres:16
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
<<: *db_env
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:latest
ports:
- 6379:6379
restart: unless-stopped
volumes:
- redis-data:/data
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 5s
timeout: 5s
retries: 5
volumes:
app-storage:
postgres-data:
redis-data:

View file

@ -24,8 +24,6 @@ module Maybe
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
config.active_job.queue_adapter = :good_job
# TODO: This is here for incremental adoption of localization. This can be removed when all translations are implemented.
config.i18n.fallbacks = true

View file

@ -1,8 +1,10 @@
development:
adapter: postgresql
adapter: async
test:
adapter: test
production:
adapter: postgresql
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: maybe_production

View file

@ -1 +1 @@
HMC62biPQuF61XA8tnd/kvwdV2xr/zpfJxG+IHNgGtpuvPXi9oS+YemBGMLte+1Q7elzAAbmKg73699hVLkRcBCk/FaMQjGRF2lnJ9MpxSR/br8Uma2bSH40lIEjxAfzjr4JPSfsHxlArF30hfd+B9obPDOptLQbpENPBsmiuEHX7S0Y8SmKuzDUVrvdfeLoVuMiAZqOP5izpBAbXfvMjI3YH70iJAaPlfAxQqR89O2nSt+N27siyyfkypE3NHQKZFz+Rmo8uJDlaD3eo/uvQN4xsgRCMUar4X2iY4UOd+MIGAPqLzIUhhJ56G5MRDJ4XpJA6RDuGFc/LNyxdXt0WinUX8Yz7zKiKah1NkEhTkH+b2ylFbsN6cjlqcX0yw8Gw8B4osyHQGnj7Tuf1c8k1z3gBoaQALm8zxKCaJ9k6CopVM2GmbpCLcJqjN1L71wCe6MiWsv9LDF/pwuZNG6hWn0oykdkWeBEQyK8g4Wo1AHqgEi8XtRwbaX6yugO5WQFhjQG/LzXcG02E5Co5/r/G7ZSFpRC9ngoOx3LY6MihPRkTIOumCg3HHtAsWBeHe4L/rDIe4A=--hlLxVbnyuYXf7Rku--A6Cwdr3CAW6bRkl1rcRmRw==
Be5nAlhacgJFHZJBgO8noswyX/VOrmkMem7wS3YQhoogzG0MCSVxCAVMbFyYFYUwqZrSPkAqUTpgH5OJJ1FB1gZfL9IYYWnEdTzMxM7IvhdDwYllYcM6smbvZEbOiqxLs9VdfC/qFS+1iFtsezBaqxfGdANJsJt3TxoRWl/ZbQ4Od1s0BNkMis1CDZt5RMEQlTz813cE5sXBlxhqEr9/2CaktwPIe5S/Oxrwo8vPFBvrNdox8BysiK9WDik8jJFSVwPSCvg43/MaIJUT0cOILdSxqrATXV143/h6ghNYtrJgoUNFT7wuu0FTU/ovTgtTqQEKG+7PDO1WLFn606bVknjPwfNMGBa9hX3LbRErDDIXNq69um9fPZ8Yq5f9jP++dPbAqbWBEg+JYsZmDgzr7LmtXVzQgAcuMkHaBbL8uxod8S1B6qhXhLNc8Dd1oeHVu0kcLFO2zaqdYRFNEY30JSjjXlG3GExXQE6aEluXvdF2gj9Hjhp7tEXZEJbIx+ZFy+6Xbrd1E2BE8AZUbalExAfudkPSYlAZ+z3fWc2RlNIuBzTYDOWH9Ai8mqsdyGNVEyizXQ==--j/6QtlLtP4mYXIFw--c+AKfDPo9stantWni+u+4Q==

View file

@ -1,22 +1,7 @@
default: &default
adapter: postgresql
encoding: unicode
# Note on DB_POOL_SIZE:
# -------------------------------------------------------------------------------------------------------------
# To optimize for the simplest self-hosting setup, we run ActionCable, GoodJob, and Rails in the same process.
#
# This requires DB connections for each:
#
# Puma: Requires 3 connections (Rails default)
# ActionCable: 5 connections (Rails defaults to 4 workers + 1 listener for Postgres adapter)
# GoodJob: 15 connections to run in "async" mode. See `good_job.rb` for the breakdown.
# --------------------------------------------------------------------------------------------
# Total: 23 connections
#
# We default to this value so that self-hosters don't need to configure anything. Hosted mode will require
# a different pool size, as we run ActionCable, GoodJob, and Rails in separate processes.
#
pool: <%= ENV.fetch("DB_POOL_SIZE") { 23 } %>
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %>
host: <%= ENV.fetch("DB_HOST") { "127.0.0.1" } %>
port: <%= ENV.fetch("DB_PORT") { "5432" } %>
user: <%= ENV.fetch("POSTGRES_USER") { nil } %>

View file

@ -69,11 +69,12 @@ Rails.application.configure do
# want to log everything, set the level to "debug".
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
# Use a different cache store in production.
# config.cache_store = :mem_cache_store
if ENV["CACHE_REDIS_URL"].present?
config.cache_store = :redis_cache_store, { url: ENV["CACHE_REDIS_URL"] }
end
config.action_mailer.perform_caching = false
config.action_mailer.deliver_later_queue_name = :high_priority
config.action_mailer.default_url_options = { host: ENV["APP_DOMAIN"] }
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
@ -105,4 +106,7 @@ Rails.application.configure do
# ]
# Skip DNS rebinding protection for the default health check endpoint.
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
# set REDIS_URL for Sidekiq to use Redis
config.active_job.queue_adapter = :sidekiq
end

View file

@ -1,33 +0,0 @@
Rails.application.configure do
config.good_job.enable_cron = true
if ENV["UPGRADES_ENABLED"] == "true"
config.good_job.cron = {
auto_upgrade: {
cron: "every 2 minutes",
class: "AutoUpgradeJob",
description: "Check for new versions of the app and upgrade if necessary"
}
}
end
config.good_job.on_thread_error = ->(exception) { Rails.error.report(exception) }
# 7 dedicated queue threads + 5 catch-all threads + 3 for job listener, cron, executor = 15 threads allocated
# `latency_low` queue for jobs ~30s
# `latency_medium` queue for jobs ~1-2 min
# `latency_high` queue for jobs ~5+ min
config.good_job.queues = "latency_low:2;latency_low,latency_medium:3;latency_low,latency_medium,latency_high:2;*"
# Auth for jobs admin dashboard
ActiveSupport.on_load(:good_job_application_controller) do
before_action do
raise ActionController::RoutingError.new("Not Found") unless current_user&.super_admin? || Rails.env.development?
end
def current_user
session = Session.find_by(id: cookies.signed[:session_token])
session&.user
end
end
end

View file

@ -0,0 +1,9 @@
require "sidekiq/web"
Sidekiq::Web.use(Rack::Auth::Basic) do |username, password|
configured_username = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_USERNAME", "maybe"))
configured_password = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_PASSWORD", "maybe"))
ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), configured_username) &&
ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), configured_password)
end

View file

@ -1,13 +0,0 @@
---
en:
upgrader:
deployer:
null_deployer:
success_message: 'No-op: null deployer initiated deploy successfully'
render:
deploy_log_error: 'Failed to deploy %{type} %{commit_sha} to Render: %{error_message}'
deploy_log_info: Deploying %{type} %{commit_sha} to Render...
error_message_failed_deploy: Failed to deploy to Render
error_message_not_set: Render deploy hook URL is not set
success_message: 'Triggered deployment to Render for commit: %{commit_sha}'
troubleshooting_url: https://render.com/docs/deploy-hooks

View file

@ -11,11 +11,6 @@ en:
generate_tokens: Generate new code
generated_tokens: Generated codes
title: Require invite code for signup
provider_settings:
description: Configure settings for your hosting provider
render_deploy_hook_label: Render Deploy Hook URL
render_deploy_hook_placeholder: https://api.render.com/deploy/srv-xyz...
title: Provider Settings
show:
general: General Settings
invites: Invite Codes
@ -38,14 +33,4 @@ en:
success: Settings updated
clear_cache:
cache_cleared: Data cache has been cleared. This may take a few moments to complete.
upgrade_settings:
description: Configure how your application receives updates
latest_commit_description: Automatically update to the latest commit (unstable)
latest_commit_title: Latest Commit
latest_release_description: Automatically update to the most recent release
(stable)
latest_release_title: Latest Release
manual_description: You control when to download and install updates
manual_title: Manual
title: Auto Upgrade
not_authorized: You are not authorized to perform this action

View file

@ -10,8 +10,3 @@ en:
label: Amount
syncing_notice:
syncing: Syncing accounts data...
upgrade_notification:
app_upgraded: The app has been upgraded to %{version}.
dismiss: Dismiss
new_version_available: A new version of Maybe is available for upgrade.
upgrade_now: Upgrade Now

View file

@ -1,10 +0,0 @@
---
en:
upgrades:
acknowledge:
upgrade_complete_dismiss: We hope you enjoy the new features!
upgrade_dismissed: Upgrade dismissed
upgrade_not_available: Upgrade not available
upgrade_not_found: Upgrade not found
deploy:
upgrade_not_found: Upgrade not found

View file

@ -1,3 +1,5 @@
require "sidekiq/web"
Rails.application.routes.draw do
# MFA routes
resource :mfa, controller: "mfa", only: [ :new, :create ] do
@ -6,7 +8,8 @@ Rails.application.routes.draw do
delete :disable
end
mount GoodJob::Engine => "good_job"
# Uses basic auth - see config/initializers/sidekiq.rb
mount Sidekiq::Web => "/sidekiq"
get "changelog", to: "pages#changelog"
get "feedback", to: "pages#feedback"
@ -158,14 +161,6 @@ Rails.application.routes.draw do
get :accept, on: :member
end
# For managing self-hosted upgrades and release notifications
resources :upgrades, only: [] do
member do
post :acknowledge
post :deploy
end
end
resources :currencies, only: %i[show]
resources :impersonation_sessions, only: [ :create ] do

5
config/sidekiq.yml Normal file
View file

@ -0,0 +1,5 @@
concurrency: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %>
queues:
- [high_priority, 7]
- [medium_priority, 2]
- [low_priority, 1]

View file

@ -23,15 +23,3 @@ cloudflare:
request_checksum_calculation: "when_required"
response_checksum_validation: "when_required"
# Removed in #702. Uncomment, add gems, update .env.example to enable.
#google:
# service: GCS
# project: <%#= ENV["GCS_PROJECT"] %>
# credentials: <%#= Rails.root.join("gcp-storage-keyfile.json") %>
# bucket: <%#= ENV["GCS_BUCKET"] %>
#azure:
# service: AzureStorage
# storage_account_name: <%#= ENV["AZURE_STORAGE_ACCOUNT_NAME"] %>
# storage_access_key: <%#= ENV["AZURE_STORAGE_ACCESS_KEY"] %>
# container: <%#= ENV["AZURE_STORAGE_CONTAINER"] %>

View file

@ -20,11 +20,5 @@ class CreateStockExchanges < ActiveRecord::Migration[7.2]
add_index :stock_exchanges, :country
add_index :stock_exchanges, :country_code
add_index :stock_exchanges, :currency_code
reversible do |dir|
dir.up do
load Rails.root.join('db/seeds/exchanges.rb')
end
end
end
end

View file

@ -0,0 +1,14 @@
class RemoveGoodJob < ActiveRecord::Migration[7.2]
def up
drop_table :good_job_batches
drop_table :good_job_executions
drop_table :good_job_processes
drop_table :good_job_settings
drop_table :good_jobs
end
def down
# Add the tables back if needed - see schema.rb for the full table definitions
raise ActiveRecord::IrreversibleMigration
end
end

View file

@ -0,0 +1,6 @@
class RemoveSelfHostUpgrades < ActiveRecord::Migration[7.2]
def change
remove_column :users, :last_prompted_upgrade_commit_sha
remove_column :users, :last_alerted_upgrade_commit_sha
end
end

92
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_03_16_122019) do
ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -244,94 +244,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_16_122019) do
t.boolean "data_enrichment_enabled", default: false
end
create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.text "description"
t.jsonb "serialized_properties"
t.text "on_finish"
t.text "on_success"
t.text "on_discard"
t.text "callback_queue_name"
t.integer "callback_priority"
t.datetime "enqueued_at"
t.datetime "discarded_at"
t.datetime "finished_at"
end
create_table "good_job_executions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.uuid "active_job_id", null: false
t.text "job_class"
t.text "queue_name"
t.jsonb "serialized_params"
t.datetime "scheduled_at"
t.datetime "finished_at"
t.text "error"
t.integer "error_event", limit: 2
t.text "error_backtrace", array: true
t.uuid "process_id"
t.interval "duration"
t.index ["active_job_id", "created_at"], name: "index_good_job_executions_on_active_job_id_and_created_at"
t.index ["process_id", "created_at"], name: "index_good_job_executions_on_process_id_and_created_at"
end
create_table "good_job_processes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "state"
t.integer "lock_type", limit: 2
end
create_table "good_job_settings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.text "key"
t.jsonb "value"
t.index ["key"], name: "index_good_job_settings_on_key", unique: true
end
create_table "good_jobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.text "queue_name"
t.integer "priority"
t.jsonb "serialized_params"
t.datetime "scheduled_at"
t.datetime "performed_at"
t.datetime "finished_at"
t.text "error"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.uuid "active_job_id"
t.text "concurrency_key"
t.text "cron_key"
t.uuid "retried_good_job_id"
t.datetime "cron_at"
t.uuid "batch_id"
t.uuid "batch_callback_id"
t.boolean "is_discrete"
t.integer "executions_count"
t.text "job_class"
t.integer "error_event", limit: 2
t.text "labels", array: true
t.uuid "locked_by_id"
t.datetime "locked_at"
t.index ["active_job_id", "created_at"], name: "index_good_jobs_on_active_job_id_and_created_at"
t.index ["batch_callback_id"], name: "index_good_jobs_on_batch_callback_id", where: "(batch_callback_id IS NOT NULL)"
t.index ["batch_id"], name: "index_good_jobs_on_batch_id", where: "(batch_id IS NOT NULL)"
t.index ["concurrency_key"], name: "index_good_jobs_on_concurrency_key_when_unfinished", where: "(finished_at IS NULL)"
t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at_cond", where: "(cron_key IS NOT NULL)"
t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at_cond", unique: true, where: "(cron_key IS NOT NULL)"
t.index ["finished_at"], name: "index_good_jobs_jobs_on_finished_at", where: "((retried_good_job_id IS NULL) AND (finished_at IS NOT NULL))"
t.index ["labels"], name: "index_good_jobs_on_labels", where: "(labels IS NOT NULL)", using: :gin
t.index ["locked_by_id"], name: "index_good_jobs_on_locked_by_id", where: "(locked_by_id IS NOT NULL)"
t.index ["priority", "created_at"], name: "index_good_job_jobs_for_candidate_lookup", where: "(finished_at IS NULL)"
t.index ["priority", "created_at"], name: "index_good_jobs_jobs_on_priority_created_at_when_unfinished", order: { priority: "DESC NULLS LAST" }, where: "(finished_at IS NULL)"
t.index ["priority", "scheduled_at"], name: "index_good_jobs_on_priority_scheduled_at_unfinished_unlocked", where: "((finished_at IS NULL) AND (locked_by_id IS NULL))"
t.index ["queue_name", "scheduled_at"], name: "index_good_jobs_on_queue_name_and_scheduled_at", where: "(finished_at IS NULL)"
t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)"
end
create_table "impersonation_session_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "impersonation_session_id", null: false
t.string "controller"
@ -652,8 +564,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_16_122019) do
t.string "password_digest"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "last_prompted_upgrade_commit_sha"
t.string "last_alerted_upgrade_commit_sha"
t.string "role", default: "member", null: false
t.boolean "active", default: true, null: false
t.datetime "onboarded_at"

0
db/seeds/.keep Normal file
View file

View file

@ -1,36 +0,0 @@
# Load exchanges from YAML configuration
exchanges_config = YAML.safe_load(
File.read(Rails.root.join('config', 'exchanges.yml')),
permitted_classes: [],
permitted_symbols: [],
aliases: true
)
exchanges_config.each do |exchange|
next unless exchange['mic'].present? # Skip any invalid entries
StockExchange.find_or_create_by!(mic: exchange['mic']) do |ex|
ex.name = exchange['name']
ex.acronym = exchange['acronym']
ex.country = exchange['country']
ex.country_code = exchange['country_code']
ex.city = exchange['city']
ex.website = exchange['website']
# Timezone details
if exchange['timezone']
ex.timezone_name = exchange['timezone']['timezone']
ex.timezone_abbr = exchange['timezone']['abbr']
ex.timezone_abbr_dst = exchange['timezone']['abbr_dst']
end
# Currency details
if exchange['currency']
ex.currency_code = exchange['currency']['code']
ex.currency_symbol = exchange['currency']['symbol']
ex.currency_name = exchange['currency']['name']
end
end
end
puts "Created #{StockExchange.count} stock exchanges"

View file

@ -1,74 +0,0 @@
# ===========================================================================
# Example Docker Compose file
# ===========================================================================
#
# Purpose:
# --------
#
# This file is an example Docker Compose configuration for self hosting
# Maybe on your local machine or on a cloud VPS.
#
# The configuration below is a "standard" setup, but may require modification
# for your specific environment.
#
# Setup:
# ------
#
# To run this, you should read the setup guide:
#
# https://github.com/maybe-finance/maybe/blob/main/docs/hosting/docker.md
#
# Troubleshooting:
# ----------------
#
# If you run into problems, you should open a Discussion here:
#
# https://github.com/maybe-finance/maybe/discussions/categories/general
#
services:
app:
image: ghcr.io/maybe-finance/maybe:latest
volumes:
- app-storage:/rails/storage
ports:
- 3000:3000
restart: unless-stopped
environment:
SELF_HOSTED: "true"
RAILS_FORCE_SSL: "false"
RAILS_ASSUME_SSL: "false"
GOOD_JOB_EXECUTION_MODE: async
SECRET_KEY_BASE: ${SECRET_KEY_BASE:?}
DB_HOST: postgres
POSTGRES_DB: ${POSTGRES_DB:-maybe_production}
POSTGRES_USER: ${POSTGRES_USER:-maybe_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_USER: ${POSTGRES_USER:-maybe_user}
POSTGRES_DB: ${POSTGRES_DB:-maybe_production}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ]
interval: 5s
timeout: 5s
retries: 5
volumes:
app-storage:
postgres-data:

View file

@ -2,10 +2,6 @@
This guide will help you setup, update, and maintain your self-hosted Maybe application with Docker Compose. Docker Compose is the most popular and recommended way to self-host the Maybe app.
If you want a _less
technical_ way to host the Maybe app, you can [host on Render](/docs/hosting/one-click-deploy.md) as an
_**alternative** to Docker Compose_.
## Setup Guide
Follow the guide below to get your app running.
@ -30,7 +26,7 @@ docker run hello-world
Open your terminal and create a directory where your app will run. Below is an example command with a recommended directory:
```bash
# Create a directory on your computer for Docker files
# Create a directory on your computer for Docker files (name whatever you'd like)
mkdir -p ~/docker-apps/maybe
# Once created, navigate your current working directory to the new folder
@ -42,8 +38,8 @@ cd ~/docker-apps/maybe
Make sure you are in the directory you just created and run the following command:
```bash
# Download the sample docker-compose.yml file from the Maybe Github repository
curl -o compose.yml https://raw.githubusercontent.com/maybe-finance/maybe/main/docker-compose.example.yml
# Download the sample compose.yml file from the Maybe Github repository
curl -o compose.yml https://raw.githubusercontent.com/maybe-finance/maybe/main/compose.example.yml
```
This command will do the following:
@ -53,6 +49,12 @@ This command will do the following:
At this point, the only file in your current working directory should be `compose.yml`.
### Step 3 (optional): Configure your environment
By default, our `compose.example.yml` file runs without any configuration. That said, if you would like extra security (important if you're running outside of a local network), you can follow the steps below to set things up.
If you're running the app locally and don't care much about security, you can skip this step.
#### Create your environment file
In order to configure the app, you will need to create a file called `.env`, which is where Docker will read environment variables from.
@ -92,7 +94,7 @@ SECRET_KEY_BASE="replacemewiththegeneratedstringfromthepriorstep"
POSTGRES_PASSWORD="replacemewithyourdesireddatabasepassword"
```
### Step 3: Test your app
### Step 4: Run the app
You are now ready to run the app. Start with the following command to make sure everything is working:
@ -106,14 +108,14 @@ Open your browser, and navigate to `http://localhost:3000`.
If everything is working, you will see the Maybe login screen.
### Step 4: Create your account
### Step 5: Create your account
The first time you run the app, you will need to register a new account by hitting "create your account" on the login page.
1. Enter your email
2. Enter a password
### Step 5: Run the app in the background
### Step 6: Run the app in the background
Most self-hosting users will want the Maybe app to run in the background on their computer so they can access it at all times. To do this, hit `Ctrl+C` to stop the running process, and then run the following command:
@ -127,7 +129,7 @@ The `-d` flag will run Docker Compose in "detached" mode. To verify it is runnin
docker compose ls
```
### Step 6: Enjoy!
### Step 7: Enjoy!
Your app is now set up. You can visit it at `http://localhost:3000` in your browser.
@ -135,7 +137,7 @@ If you find bugs or have a feature request, be sure to read through our [contrib
## How to update your app
The mechanism that updates your self-hosted Maybe app is the GHCR (Github Container Registry) Docker image that you see in the `docker-compose.yml` file:
The mechanism that updates your self-hosted Maybe app is the GHCR (Github Container Registry) Docker image that you see in the `compose.yml` file:
```yml
image: ghcr.io/maybe-finance/maybe:latest
@ -152,13 +154,13 @@ NOT_ automatically update. To update your self-hosted app, run the following com
```bash
cd ~/docker-apps/maybe # Navigate to whatever directory you configured the app in
docker compose pull # This pulls the "latest" published image from GHCR
docker compose build app # This rebuilds the app with updates
docker compose build # This rebuilds the app with updates
docker compose up --no-deps -d app # This restarts the app using the newest version
```
## How to change which updates your app receives
If you'd like to pin the app to a specific version or tag, all you need to do is edit the `docker-compose.yml` file:
If you'd like to pin the app to a specific version or tag, all you need to do is edit the `compose.yml` file:
```yml
image: ghcr.io/maybe-finance/maybe:stable
@ -168,7 +170,7 @@ After doing this, make sure and restart the app:
```bash
docker compose pull # This pulls the "latest" published image from GHCR
docker compose build app # This rebuilds the app with updates
docker compose build # This rebuilds the app with updates
docker compose up --no-deps -d app # This restarts the app using the newest version
```

View file

@ -1,90 +0,0 @@
# Deploy Maybe in One Click
Below are our "one-click deploy" options for running Maybe in the cloud:
## Render
Welcome to the one-click deploy guide for Maybe on [Render](https://render.com/)!
Render is a hosting platform with a generous free tier and makes it easy to get
started with Maybe:
- Getting started is FREE
- Up and running in <5 minutes
- Your Maybe app is automatically deployed to a live URL
### Estimated Costs
- FREE to _get up and running_
- $7 per month for a basic app (Render requires you to upgrade your database to
keep using it)
- $14+ per month for optimal performance
_**IMPORTANT:** if you plan to host Maybe on Render long-term, you MUST upgrade
your database to a paid Render service._
### Instructions
#### Step 1: Create Render Blueprint
<a href="https://render.com/deploy?repo=https://github.com/maybe-finance/maybe">
<img src="https://render.com/images/deploy-to-render-button.svg" alt="Deploy to Render" />
</a>
1. Click the button above.
2. Sign in or create your account with Render (FREE)
3. Give your blueprint a name (we suggest `Maybe`)
4. Select the `main` branch
5. You should see a section at the bottom with a "Key:Value" field
for `SECRET_KEY_BASE`. Do NOT click "generate".
6. On your computer, open a terminal and make sure you have
the [openssl](https://github.com/openssl/openssl) utility installed on your
computer. You can run `openssl --version` to verify it is installed.
7. Generate your `SECRET_KEY_BASE` by running the following command in your
terminal: `openssl rand -hex 64` ([docs](https://www.openssl.org/docs/man1.1.1/man1/rand.html)).
8. Do NOT share this value with anyone.
9. Go back to your browser and paste this value in the "Value" field
for `SECRET_KEY_BASE`
10. Click "Apply". This will take a few minutes.
11. Once complete, click on the `maybe` "Web Service". You should see a custom
URL in the format `https://maybe-abcd.onrender.com`. Click on it, and you'll
see your running Maybe app!
#### Step 2: Add your deploy hook for auto-updates
To get new releases, you will need to add your deploy hook to the app.
1. Click on the `maybe` "Web Service"
2. Click "Settings"
3. Scroll down to the end of the "Build and Deploy" section until you find the "
Deploy Hook"
4. Copy this value
5. Open your new Maybe app, click your profile, click "Self Host Settings"
6. Paste your deploy hook in the settings and save
7. You're all set!
#### Step 3 (IMPORTANT!!!): Upgrade your Render services
By default, we set you up with a FREE Render web service and a FREE postgres
database. We do this for a few reasons:
- It allows you to take self-hosted Maybe for a FREE test-drive
- It prevents newcomers from incurring unexpected hosting charges
##### Upgrade your Database (REQUIRED)
All FREE Render databases **will be deleted after a few months**. This means
that **you will lose all of your Maybe data**.
**To avoid losing data, you MUST upgrade your Render database** (a "starter"
instance is $7/month)
You can upgrade your instance directly in the Render dashboard.
##### Upgrade your Web Service (RECOMMENDED)
All FREE Render web services use a small amount of memory and "sleep" after
periods of inactivity.
For the _fastest_ Maybe experience, you should upgrade your web service (a "
starter" instance is $7/month)

View file

@ -1,60 +0,0 @@
databases:
- name: maybe
user: maybe
plan: free
services:
- type: web
plan: free
autoDeploy: false
runtime: ruby
name: maybe
repo: https://github.com/maybe-finance/maybe.git
branch: main
healthCheckPath: /up
buildCommand: "./bin/render-build.sh"
# Uncomment if you are on a paid plan, and remove RUN_DB_MIGRATIONS_IN_BUILD_STEP from below
# preDeployCommand: "bundle exec rails db:migrate"
startCommand: "bundle exec rails server"
envVars:
- key: DATABASE_URL
fromDatabase:
name: maybe
property: connectionString
- key: SELF_HOSTED
value: true
- key: HOSTING_PLATFORM
value: render
# Since the app is self-hosted, we cannot use master.key to encrypt credentials. App depends entirely on ENV variables
# https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base
#
# To generate this, run: `openssl rand -hex 64` or `rails secret`
- key: SECRET_KEY_BASE
sync: false
- key: WEB_CONCURRENCY
value: 2
- key: GOOD_JOB_EXECUTION_MODE
value: async # Typically, `external` is used in prod, but this avoids another cron service and is generally fine for a self-hoster given low traffic
# The app uses this info to know which repo to fetch latest commit data from for upgrades
# This should MATCH the `repo` and `branch` keys in the config above ALWAYS
- key: GITHUB_REPO_OWNER
value: maybe-finance
- key: GITHUB_REPO_NAME
value: maybe
- key: GITHUB_REPO_BRANCH
value: main
# Required to allow your self-hosted instance to be able to upgrade itself
- key: UPGRADES_ENABLED
value: true
# If you upgrade your instance to a paid plan, you can set this to false (or remove it)
# See note in `render-build.sh` script.
- key: RUN_DB_MIGRATIONS_IN_BUILD_STEP
value: true

View file

@ -37,17 +37,6 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
assert_nil Session.find_by(id: session_record.id)
end
test "super admins can access the jobs page" do
sign_in users(:maybe_support_staff)
get good_job_url
assert_redirected_to "http://www.example.com/good_job/jobs?locale=en"
end
test "non-super admins cannot access the jobs page" do
get good_job_url
assert_response :not_found
end
test "redirects to MFA verification when MFA enabled" do
@user.setup_mfa!
@user.enable_mfa!

View file

@ -25,7 +25,7 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
end
assert_raises(RuntimeError, "Settings not available on non-self-hosted instance") do
patch settings_hosting_url, params: { setting: { render_deploy_hook: "https://example.com" } }
patch settings_hosting_url, params: { setting: { require_invite_for_signup: true } }
end
end
@ -40,25 +40,11 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
test "can update settings when self hosting is enabled" do
with_self_hosting do
NEW_RENDER_DEPLOY_HOOK = "https://api.render.com/deploy/srv-abc123"
assert_nil Setting.render_deploy_hook
assert_nil Setting.synth_api_key
patch settings_hosting_url, params: { setting: { render_deploy_hook: NEW_RENDER_DEPLOY_HOOK } }
patch settings_hosting_url, params: { setting: { synth_api_key: "1234567890" } }
assert_equal NEW_RENDER_DEPLOY_HOOK, Setting.render_deploy_hook
end
end
test "can choose auto upgrades mode with a deploy hook" do
with_self_hosting do
NEW_RENDER_DEPLOY_HOOK = "https://api.render.com/deploy/srv-abc123"
assert_nil Setting.render_deploy_hook
patch settings_hosting_url, params: { setting: { render_deploy_hook: NEW_RENDER_DEPLOY_HOOK, upgrades_setting: "release" } }
assert_equal "auto", Setting.upgrades_mode
assert_equal "release", Setting.upgrades_target
assert_equal NEW_RENDER_DEPLOY_HOOK, Setting.render_deploy_hook
assert_equal "1234567890", Setting.synth_api_key
end
end

View file

@ -1,89 +0,0 @@
require "test_helper"
class UpgradesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@completed_upgrade = Upgrader::Upgrade.new(
"commit",
commit_sha: "47bb430954292d2fdcc81082af731a16b9587da3",
version: Semver.new("0.0.0"),
url: ""
)
@completed_upgrade.stubs(:complete?).returns(true)
@completed_upgrade.stubs(:available?).returns(false)
@available_upgrade = Upgrader::Upgrade.new(
"commit",
commit_sha: "47bb430954292d2fdcc81082af731a16b9587da4",
version: Semver.new("0.1.0"),
url: ""
)
@available_upgrade.stubs(:available?).returns(true)
@available_upgrade.stubs(:complete?).returns(false)
end
test "controller not available when upgrades are disabled" do
MOCK_COMMIT = "47bb430954292d2fdcc81082af731a16b9587da3"
post acknowledge_upgrade_url(MOCK_COMMIT)
assert_response :not_found
post deploy_upgrade_url(MOCK_COMMIT)
assert_response :not_found
end
test "should acknowledge an upgrade prompt" do
with_env_overrides UPGRADES_ENABLED: "true" do
Upgrader.stubs(:find_upgrade).returns(@available_upgrade)
post acknowledge_upgrade_url(@available_upgrade.commit_sha)
@user.reload
assert_equal @user.last_prompted_upgrade_commit_sha, @available_upgrade.commit_sha
assert :redirect
end
end
test "should acknowledge an upgrade alert" do
with_env_overrides UPGRADES_ENABLED: "true" do
Upgrader.stubs(:find_upgrade).returns(@completed_upgrade)
post acknowledge_upgrade_url(@completed_upgrade.commit_sha)
@user.reload
assert_equal @user.last_alerted_upgrade_commit_sha, @completed_upgrade.commit_sha
assert :redirect
end
end
test "should deploy an upgrade" do
with_env_overrides UPGRADES_ENABLED: "true" do
Upgrader.stubs(:find_upgrade).returns(@available_upgrade)
post deploy_upgrade_path(@available_upgrade.commit_sha)
@user.reload
assert_equal @user.last_prompted_upgrade_commit_sha, @available_upgrade.commit_sha
assert :redirect
end
end
test "should rollback user state if upgrade fails" do
with_env_overrides UPGRADES_ENABLED: "true" do
PRIOR_COMMIT = "47bb430954292d2fdcc81082af731a16b9587da2"
@user.update!(last_prompted_upgrade_commit_sha: PRIOR_COMMIT)
Upgrader.stubs(:find_upgrade).returns(@available_upgrade)
Upgrader.stubs(:upgrade_to).returns({ success: false })
post deploy_upgrade_path(@available_upgrade.commit_sha)
@user.reload
assert_equal @user.last_prompted_upgrade_commit_sha, PRIOR_COMMIT
assert :redirect
end
end
end

View file

@ -1,25 +0,0 @@
require "test_helper"
module GitRepositoryProviderInterfaceTest
extend ActiveSupport::Testing::Declarative
test "git repository provider interface" do
assert_respond_to @subject, :fetch_latest_upgrade_candidates
end
test "git repository provider response contract" do
VCR.use_cassette "git_repository_provider/fetch_latest_upgrade_candidates" do
response = @subject.fetch_latest_upgrade_candidates
assert_valid_upgrade_candidate(response[:release])
assert_valid_upgrade_candidate(response[:commit])
end
end
private
def assert_valid_upgrade_candidate(candidate)
assert_equal Semver, candidate[:version].class
assert_match URI::DEFAULT_PARSER.make_regexp, candidate[:url]
assert_match(/\A[0-9a-f]{40}\z/, candidate[:commit_sha])
end
end

View file

@ -1,7 +0,0 @@
require "test_helper"
class AutoUpgradeJobTest < ActiveJob::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -1,9 +0,0 @@
require "test_helper"
class Provider::GithubTest < ActiveSupport::TestCase
include GitRepositoryProviderInterfaceTest
setup do
@subject = Provider::Github.new(owner: "rails", name: "rails", branch: "main")
end
end

View file

@ -1,36 +0,0 @@
require "test_helper"
class UpgradeTest < ActiveSupport::TestCase
setup do
data = {
commit_sha: "latestcommit",
version: Semver.new("0.1.0-alpha.2")
}
@commit_upgrade = Upgrader::Upgrade.new "commit", data
@release_upgrade = Upgrader::Upgrade.new "release", data
end
test "available if latest commit and app not upgraded" do
Maybe.stubs(:version).returns(@commit_upgrade.version)
Maybe.stubs(:commit_sha).returns("outdatedcommitsha")
assert @commit_upgrade.available?
assert_not @release_upgrade.available?
end
test "available if latest release and app not upgraded" do
Maybe.stubs(:version).returns(Semver.new("0.1.0-alpha.1"))
Maybe.stubs(:commit_sha).returns("outdatedcommitsha")
assert @commit_upgrade.available?
assert @release_upgrade.available?
end
test "not available if app commit greater or equal to" do
Maybe.stubs(:version).returns(@commit_upgrade.version)
Maybe.stubs(:commit_sha).returns(@commit_upgrade.commit_sha)
assert_not @commit_upgrade.available?
end
end

View file

@ -1,88 +0,0 @@
require "test_helper"
class UpgraderTest < ActiveSupport::TestCase
PRIOR_COMMIT = "47bb430954292d2fdcc81082af731a16b9587da2"
CURRENT_COMMIT = "47bb430954292d2fdcc81082af731a16b9587da3"
NEXT_COMMIT = "47bb430954292d2fdcc81082af731a16b9587da4"
PRIOR_VERSION = Semver.new("0.1.0-alpha.3")
CURRENT_VERSION = Semver.new("0.1.0-alpha.4")
NEXT_VERSION = Semver.new("0.1.0-alpha.5")
# Default setup assumes app is up to date
setup do
Upgrader.config = Upgrader::Config.new({ mode: :enabled })
Maybe.stubs(:version).returns(CURRENT_VERSION)
Maybe.stubs(:commit_sha).returns(CURRENT_COMMIT)
stub_github_data(
commit: create_upgrade_stub(CURRENT_VERSION, CURRENT_COMMIT),
release: create_upgrade_stub(CURRENT_VERSION, CURRENT_COMMIT)
)
end
test "finds 1 completed upgrade, 0 available upgrades when app is up to date" do
assert_instance_of Upgrader::Upgrade, Upgrader.completed_upgrade
assert_nil Upgrader.available_upgrade
end
test "finds 1 available and 1 completed upgrade when app is on latest release but behind latest commit" do
stub_github_data(
commit: create_upgrade_stub(CURRENT_VERSION, NEXT_COMMIT),
release: create_upgrade_stub(CURRENT_VERSION, CURRENT_COMMIT)
)
assert_instance_of Upgrader::Upgrade, Upgrader.available_upgrade # commit is ahead of release
assert_instance_of Upgrader::Upgrade, Upgrader.completed_upgrade # release is completed
end
test "when app is behind latest version and latest commit is ahead of release finds release upgrade and no completed upgrades" do
Maybe.stubs(:version).returns(PRIOR_VERSION)
Maybe.stubs(:commit_sha).returns(PRIOR_COMMIT)
stub_github_data(
commit: create_upgrade_stub(CURRENT_VERSION, NEXT_COMMIT),
release: create_upgrade_stub(CURRENT_VERSION, CURRENT_COMMIT)
)
assert_equal "release", Upgrader.available_upgrade.type
assert_nil Upgrader.completed_upgrade
end
test "defaults to app version when no release is found" do
stub_github_data(
commit: create_upgrade_stub(CURRENT_VERSION, NEXT_COMMIT),
release: nil
)
# Upstream is 1 commit ahead, and we assume we're on the same release
assert_equal "commit", Upgrader.available_upgrade.type
end
test "gracefully handles empty github info" do
Provider::Github.any_instance.stubs(:fetch_latest_upgrade_candidates).returns(nil)
assert_nil Upgrader.available_upgrade
assert_nil Upgrader.completed_upgrade
end
test "deployer is null by default" do
Upgrader.config = Upgrader::Config.new({ mode: :enabled })
Upgrader::Deployer::Null.any_instance.expects(:deploy).with(nil).once
Upgrader.upgrade_to(nil)
end
private
def create_upgrade_stub(version, commit_sha)
{
version: version,
commit_sha: commit_sha,
url: ""
}
end
def stub_github_data(commit: create_upgrade_stub(LATEST_VERSION, LATEST_COMMIT), release: create_upgrade_stub(LATEST_VERSION, LATEST_COMMIT))
Provider::Github.any_instance.stubs(:fetch_latest_upgrade_candidates).returns({ commit:, release: })
end
end

File diff suppressed because one or more lines are too long

View file

@ -1,235 +0,0 @@
---
http_interactions:
- request:
method: get
uri: https://api.github.com/repos/rails/rails/releases
body:
encoding: US-ASCII
string: ""
headers:
Accept:
- application/vnd.github.v3+json
User-Agent:
- Octokit Ruby Gem 8.1.0
Content-Type:
- application/json
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
response:
status:
code: 200
message: OK
headers:
Server:
- GitHub.com
Date:
- Wed, 10 Apr 2024 19:52:56 GMT
Content-Type:
- application/json; charset=utf-8
Cache-Control:
- public, max-age=60, s-maxage=60
Vary:
- Accept, Accept-Encoding, Accept, X-Requested-With
Etag:
- W/"a032e5cc14d6dc10a55126bd742c08afc1365c4cf381d6d5ce3b4014cfbf2de5"
X-Github-Media-Type:
- github.v3; format=json
Link:
- <https://api.github.com/repositories/8514/releases?page=2>; rel="next", <https://api.github.com/repositories/8514/releases?page=4>;
rel="last"
X-Github-Api-Version-Selected:
- "2022-11-28"
Access-Control-Expose-Headers:
- ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
X-GitHub-Request-Id, Deprecation, Sunset
Access-Control-Allow-Origin:
- "*"
Strict-Transport-Security:
- max-age=31536000; includeSubdomains; preload
X-Frame-Options:
- deny
X-Content-Type-Options:
- nosniff
X-Xss-Protection:
- "0"
Referrer-Policy:
- origin-when-cross-origin, strict-origin-when-cross-origin
Content-Security-Policy:
- default-src 'none'
X-Ratelimit-Limit:
- "60"
X-Ratelimit-Remaining:
- "53"
X-Ratelimit-Reset:
- "1712781639"
X-Ratelimit-Resource:
- core
X-Ratelimit-Used:
- "7"
Accept-Ranges:
- bytes
Transfer-Encoding:
- chunked
X-Github-Request-Id:
- C8A7:A3F5F:11C7A6D:1BA83CE:6616EE18
body:
encoding: ASCII-8BIT
string: '[{"tag_name": "v7.1.3.2", "html_url": "http://localhost"}]' # manually abbreviated for clarity
recorded_at: Wed, 10 Apr 2024 19:52:56 GMT
- request:
method: get
uri: https://api.github.com/repos/rails/rails/branches/main
body:
encoding: US-ASCII
string: ""
headers:
Accept:
- application/vnd.github.v3+json
User-Agent:
- Octokit Ruby Gem 8.1.0
Content-Type:
- application/json
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
response:
status:
code: 200
message: OK
headers:
Server:
- GitHub.com
Date:
- Wed, 10 Apr 2024 19:52:57 GMT
Content-Type:
- application/json; charset=utf-8
Cache-Control:
- public, max-age=60, s-maxage=60
Vary:
- Accept, Accept-Encoding, Accept, X-Requested-With
Etag:
- W/"bbcf30919f0ef5fae2b2a28f58d50e3fb2cea8aa75418d5f2b919a7f857b27d0"
X-Github-Media-Type:
- github.v3; format=json
X-Github-Api-Version-Selected:
- "2022-11-28"
Access-Control-Expose-Headers:
- ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
X-GitHub-Request-Id, Deprecation, Sunset
Access-Control-Allow-Origin:
- "*"
Strict-Transport-Security:
- max-age=31536000; includeSubdomains; preload
X-Frame-Options:
- deny
X-Content-Type-Options:
- nosniff
X-Xss-Protection:
- "0"
Referrer-Policy:
- origin-when-cross-origin, strict-origin-when-cross-origin
Content-Security-Policy:
- default-src 'none'
X-Ratelimit-Limit:
- "60"
X-Ratelimit-Remaining:
- "52"
X-Ratelimit-Reset:
- "1712781639"
X-Ratelimit-Resource:
- core
X-Ratelimit-Used:
- "8"
Accept-Ranges:
- bytes
Content-Length:
- "3964"
X-Github-Request-Id:
- C8A8:281896:11B1812:1B69B2F:6616EE19
body:
encoding: ASCII-8BIT
# manually abbreviated for clarity
string: '{"commit":{"sha":"84997578c59aa88fe114cef176115f1612b6de6b", "html_url":"https://github.com/rails/rails/commit/84997578c59aa88fe114cef176115f1612b6de6b"}}'
recorded_at: Wed, 10 Apr 2024 19:52:57 GMT
- request:
method: get
uri: https://api.github.com/repos/rails/rails/commits/v7.1.3.2
body:
encoding: US-ASCII
string: ""
headers:
Accept:
- application/vnd.github.v3+json
User-Agent:
- Octokit Ruby Gem 8.1.0
Content-Type:
- application/json
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
response:
status:
code: 200
message: OK
headers:
Server:
- GitHub.com
Date:
- Wed, 10 Apr 2024 19:52:57 GMT
Content-Type:
- application/json; charset=utf-8
Cache-Control:
- public, max-age=60, s-maxage=60
Vary:
- Accept, Accept-Encoding, Accept, X-Requested-With
Etag:
- W/"0668fc459669113a200777ee9ddd56a6ca2efb647894b006d3966504c7c82f13"
Last-Modified:
- Wed, 21 Feb 2024 21:43:55 GMT
X-Github-Media-Type:
- github.v3; format=json
X-Github-Api-Version-Selected:
- "2022-11-28"
Access-Control-Expose-Headers:
- ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
X-GitHub-Request-Id, Deprecation, Sunset
Access-Control-Allow-Origin:
- "*"
Strict-Transport-Security:
- max-age=31536000; includeSubdomains; preload
X-Frame-Options:
- deny
X-Content-Type-Options:
- nosniff
X-Xss-Protection:
- "0"
Referrer-Policy:
- origin-when-cross-origin, strict-origin-when-cross-origin
Content-Security-Policy:
- default-src 'none'
X-Ratelimit-Limit:
- "60"
X-Ratelimit-Remaining:
- "51"
X-Ratelimit-Reset:
- "1712781639"
X-Ratelimit-Resource:
- core
X-Ratelimit-Used:
- "9"
Accept-Ranges:
- bytes
Transfer-Encoding:
- chunked
X-Github-Request-Id:
- C8A9:23AA82:11FFCB8:1C057CD:6616EE19
body:
encoding: ASCII-8BIT
# manually abbreviated for clarity
string: '{"sha":"6f0d1ad14b92b9f5906e44740fce8b4f1c7075dc"}'
recorded_at: Wed, 10 Apr 2024 19:52:57 GMT
recorded_with: VCR 6.2.0