1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 05:09:38 +02:00

Add zero-config self hosting on Render (#612)

* v1 of backend implementation for self hosting

* Add docs

* Add upgrades controller

* Add global helpers for self hosting mode

* Add self host settings controller

* Conditionally show self hosting settings

* Environment and config updates

* Complete upgrade prompting flow

* Update config for forked repo

* Move configuration of github provider within class

* Add upgrades cron

* Update deploy button

* Update guides

* Fix render deployer

* Typo

* Enable auto upgrades

* Fix cron

* Make upgrade modes more clear and consistent

* Trigger new available version

* Fix logic for displaying upgrade prompts

* Finish implementation

* Fix regression

* Trigger new version

* Add i18n translations

* trigger new version

* reduce caching time for testing

* Decrease cache for testing

* trigger upgrade

* trigger upgrade

* Only trigger deploy once

* trigger upgrade

* If target is commit, always upgrade if any upgrade is available

* trigger upgrade

* trigger upgrade

* Test release

* Change back to maybe repo for defaults

* Fix lint errors

* Clearer naming

* Fix relative link

* Add abs path

* Relative link

* Update docs
This commit is contained in:
Zach Gollwitzer 2024-04-13 09:28:45 -04:00 committed by GitHub
parent 2bbf120e2f
commit 5aca2ff9b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 1356 additions and 111 deletions

View file

@ -25,5 +25,34 @@ APP_DOMAIN=
# 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. # 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= SENTRY_DSN=
# Used to enable specific features unique to the hosted version of Maybe. There's a very high likelihood that you don't need to change this value. # If enabled, an invite code generated by `rake invites:create` is required to sign up as a new user.
HOSTED=false # This is useful for controlling who can sign up for your Maybe instance.
REQUIRE_INVITE_CODE=false
# Enables self hosting features
SELF_HOSTING_ENABLED=false
# The hosting platform used to deploy the app (e.g. "render")
# `localhost` (or unset) is used for local development and testing
HOSTING_PLATFORM=localhost
# ======================================================================================================
# 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

View file

@ -33,6 +33,8 @@ gem "ransack"
gem "stackprof" gem "stackprof"
gem "sentry-ruby" gem "sentry-ruby"
gem "sentry-rails" gem "sentry-rails"
gem "rails-settings-cached"
gem "octokit"
# Other # Other
gem "bcrypt", "~> 3.1.7" gem "bcrypt", "~> 3.1.7"

View file

@ -254,6 +254,10 @@ GEM
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.3-x86_64-linux) nokogiri (1.16.3-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
octokit (8.1.0)
base64
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (8.0.2) pagy (8.0.2)
parallel (1.24.0) parallel (1.24.0)
parser (3.3.0.5) parser (3.3.0.5)
@ -291,6 +295,9 @@ GEM
rails-i18n (7.0.8) rails-i18n (7.0.8)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8) railties (>= 6.0.0, < 8)
rails-settings-cached (2.9.4)
activerecord (>= 5.0.0)
railties (>= 5.0.0)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.2.1) rake (13.2.1)
ransack (4.1.1) ransack (4.1.1)
@ -349,6 +356,9 @@ GEM
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rubyzip (2.3.2) rubyzip (2.3.2)
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
selenium-webdriver (4.19.0) selenium-webdriver (4.19.0)
base64 (~> 0.2) base64 (~> 0.2)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
@ -435,11 +445,13 @@ DEPENDENCIES
letter_opener letter_opener
lucide-rails! lucide-rails!
mocha mocha
octokit
pagy pagy
pg (~> 1.5) pg (~> 1.5)
propshaft propshaft
puma (>= 5.0) puma (>= 5.0)
rails! rails!
rails-settings-cached
ransack ransack
redis (>= 4.0.1) redis (>= 4.0.1)
rubocop-rails-omakase rubocop-rails-omakase

View file

@ -17,6 +17,19 @@ We spent the better part of $1,000,000 building the app (employees + contractors
We're now reviving the product as a fully open-source project. The goal is to let you run the app yourself, for free, and use it to manage your own finances and eventually offer a hosted version of the app for a small monthly fee. We're now reviving the product as a fully open-source project. The goal is to let you run the app yourself, for free, and use it to manage your own finances and eventually offer a hosted version of the app for a small monthly fee.
## Self Hosting
You can find [detailed setup guides for self hosting here](docs/self-hosting.md).
### One-Click Render deploy (recommended)
<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. Follow the instructions in the [Render self-hosting guide](docs/self-hosting/render.md)
## Local Development Setup ## Local Development Setup
### Requirements ### Requirements
@ -80,14 +93,6 @@ Before contributing, you'll likely find it helpful to [understand context and ge
Once you've done that, please visit our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md) to get started! Once you've done that, please visit our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md) to get started!
## Self Hosting
Our long term goal is to make self-hosting as easy as possible. That said, during these early stages of building the product, we are focusing our efforts on development.
We will update this section as we get closer to an initial release.
Please see our [guide on self hosting here](https://github.com/maybe-finance/maybe/wiki/Self-Hosting-Setup-Guide).
## Repo Activity ## Repo Activity
![Repo Activity](https://repobeats.axiom.co/api/embed/7866c9790deba0baf63ca1688b209130b306ea4e.svg "Repobeats analytics image") ![Repo Activity](https://repobeats.axiom.co/api/embed/7866c9790deba0baf63ca1688b209130b306ea4e.svg "Repobeats analytics image")

View file

@ -1,5 +1,5 @@
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include Authentication include Authentication, Invitable, SelfHostable
include Pagy::Backend include Pagy::Backend
before_action :sync_accounts before_action :sync_accounts
@ -11,11 +11,6 @@ class ApplicationController < ActionController::Base
private private
def hosted_app?
ENV["HOSTED"] == "true"
end
helper_method :hosted_app?
def sync_accounts def sync_accounts
return if Current.user.blank? return if Current.user.blank?

View file

@ -0,0 +1,12 @@
module Invitable
extend ActiveSupport::Concern
included do
helper_method :invite_code_required?
end
private
def invite_code_required?
ENV["REQUIRE_INVITE_CODE"] == "true"
end
end

View file

@ -0,0 +1,12 @@
module SelfHostable
extend ActiveSupport::Concern
included do
helper_method :self_hosted?
end
private
def self_hosted?
ENV["SELF_HOSTING_ENABLED"] == "true"
end
end

View file

@ -4,7 +4,7 @@ class RegistrationsController < ApplicationController
layout "auth" layout "auth"
before_action :set_user, only: :create before_action :set_user, only: :create
before_action :claim_invite_code, only: :create, if: :hosted_app? before_action :claim_invite_code, only: :create, if: :invite_code_required?
def new def new
@user = User.new @user = User.new

View file

@ -0,0 +1,46 @@
class Settings::SelfHostingController < ApplicationController
before_action :verify_self_hosting_enabled
def edit
end
def update
if all_updates_valid?
self_hosting_params.keys.each do |key|
Setting.send("#{key}=", self_hosting_params[key].strip)
end
redirect_to edit_settings_self_hosting_path, notice: t(".success")
else
flash.now[:error] = @errors.first.message
render :edit, status: :unprocessable_entity
end
end
private
def all_updates_valid?
@errors = ActiveModel::Errors.new(Setting)
self_hosting_params.keys.each do |key|
setting = Setting.new(var: key)
setting.value = self_hosting_params[key].strip
unless setting.valid?
@errors.merge!(setting.errors)
end
end
if self_hosting_params[:upgrades_mode] == "auto" && self_hosting_params[:render_deploy_hook].blank?
@errors.add(:render_deploy_hook, t("settings.self_hosting.update.render_deploy_hook_error"))
end
@errors.empty?
end
def self_hosting_params
params.require(:setting).permit(:render_deploy_hook, :upgrades_mode, :upgrades_target)
end
def verify_self_hosting_enabled
head :not_found unless self_hosted?
end
end

View file

@ -0,0 +1,56 @@
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

@ -37,7 +37,6 @@ module ApplicationHelper
render partial: "shared/sidebar_modal", locals: { content: content } render partial: "shared/sidebar_modal", locals: { content: content }
end end
def sidebar_link_to(name, path, options = {}) def sidebar_link_to(name, path, options = {})
base_class_names = [ "block", "border", "border-transparent", "rounded-xl", "-ml-2", "p-2", "text-sm", "font-medium", "text-gray-500", "flex", "items-center" ] base_class_names = [ "block", "border", "border-transparent", "rounded-xl", "-ml-2", "p-2", "text-sm", "font-medium", "text-gray-500", "flex", "items-center" ]
hover_class_names = [ "hover:bg-white", "hover:border-alpha-black-50", "hover:text-gray-900", "hover:shadow-xs" ] hover_class_names = [ "hover:bg-white", "hover:border-alpha-black-50", "hover:text-gray-900", "hover:shadow-xs" ]

View file

@ -0,0 +1,2 @@
module Settings::SelfHostingHelper
end

View file

@ -0,0 +1,13 @@
module UpgradesHelper
def upgrade_notification
return nil unless ENV["UPGRADES_ENABLED"] == "true"
completed_upgrade = Upgrader.completed_upgrade
return completed_upgrade if completed_upgrade && Current.user.last_alerted_upgrade_commit_sha != completed_upgrade.commit_sha
available_upgrade = Upgrader.available_upgrade
if available_upgrade && Setting.upgrades_mode == "manual" && Current.user.last_prompted_upgrade_commit_sha != available_upgrade.commit_sha
available_upgrade
end
end
end

View file

@ -0,0 +1,31 @@
class AutoUpgradeJob < ApplicationJob
queue_as :default
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

@ -9,5 +9,9 @@ module Providable
def exchange_rates_provider def exchange_rates_provider
Provider::Synth.new Provider::Synth.new
end end
def git_repository_provider
Provider::Github.new
end
end end
end end

View file

@ -0,0 +1,47 @@
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: 2.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
end
private
def repo
"#{owner}/#{name}"
end
end

19
app/models/setting.rb Normal file
View file

@ -0,0 +1,19 @@
# Dynamic settings the user can change within the app (helpful for self-hosting)
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] } }
end

57
app/models/upgrader.rb Normal file
View file

@ -0,0 +1,57 @@
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

@ -0,0 +1,17 @@
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

@ -0,0 +1,12 @@
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

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

View file

@ -0,0 +1,41 @@
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

@ -0,0 +1,11 @@
module Upgrader::Provided
extend ActiveSupport::Concern
include Providable
class_methods do
private
def fetch_latest_upgrade_candidates_from_provider
git_repository_provider.fetch_latest_upgrade_candidates
end
end
end

View file

@ -0,0 +1,27 @@
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?
version > Maybe.version || (version == Maybe.version && commit_sha != Maybe.commit_sha)
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

@ -10,4 +10,20 @@ class User < ApplicationRecord
generates_token_for :password_reset, expires_in: 15.minutes do generates_token_for :password_reset, expires_in: 15.minutes do
password_salt&.last(10) password_salt&.last(10)
end 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
end end

View file

@ -40,7 +40,13 @@
class="hidden absolute min-w-[200px] z-10 top-10 right-0 bg-white p-1 rounded-sm shadow-xs border border-alpha-black-25 w-fit"> class="hidden absolute min-w-[200px] z-10 top-10 right-0 bg-white p-1 rounded-sm shadow-xs border border-alpha-black-25 w-fit">
<%= link_to edit_settings_path, class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %> <%= link_to edit_settings_path, class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
<%= lucide_icon("pencil-line", class: "w-5 h-5 text-gray-500 shrink-0") %> <%= lucide_icon("pencil-line", class: "w-5 h-5 text-gray-500 shrink-0") %>
<span class="text-gray-900 text-sm">Settings</span> <span class="text-gray-900 text-sm">General Settings</span>
<% end %>
<% if self_hosted? %>
<%= link_to edit_settings_self_hosting_path, class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
<%= lucide_icon("pencil-line", class: "w-5 h-5 text-gray-500 shrink-0") %>
<span class="text-gray-900 text-sm">Self Host Settings</span>
<% end %>
<% end %> <% end %>
<%= button_to session_path, method: :delete, class: "w-full text-gray-900 flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %> <%= button_to session_path, method: :delete, class: "w-full text-gray-900 flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
<%= lucide_icon("log-out", class: "w-5 h-5 shrink-0") %> <%= lucide_icon("log-out", class: "w-5 h-5 shrink-0") %>
@ -86,5 +92,6 @@
</div> </div>
<%= turbo_frame_tag "modal" %> <%= turbo_frame_tag "modal" %>
<%= render "shared/custom_confirm_modal" %> <%= render "shared/custom_confirm_modal" %>
<%= render "shared/upgrade_notification" %>
</body> </body>
</html> </html>

View file

@ -1,19 +1,13 @@
<% <%
header_title t(".title") header_title t(".title")
%> %>
<%= form_with model: @user, url: registration_path do |form| %> <%= form_with model: @user, url: registration_path do |form| %>
<%= auth_messages form %> <%= auth_messages form %>
<%= form.email_field :email, autofocus: false, autocomplete: "email", required: "required", placeholder: "you@example.com", label: true %> <%= form.email_field :email, autofocus: false, autocomplete: "email", required: "required", placeholder: "you@example.com", label: true %>
<%= form.password_field :password, autocomplete: "new-password", required: "required", label: true %> <%= form.password_field :password, autocomplete: "new-password", required: "required", label: true %>
<%= form.password_field :password_confirmation, autocomplete: "new-password", required: "required", label: true %> <%= form.password_field :password_confirmation, autocomplete: "new-password", required: "required", label: true %>
<% if invite_code_required? %>
<% if hosted_app? %>
<%= form.password_field :invite_code, required: "required", label: true %> <%= form.password_field :invite_code, required: "required", label: true %>
<% end %> <% end %>
<%= form.submit %> <%= form.submit %>
<% end %> <% end %>

View file

@ -0,0 +1,40 @@
<div class="space-y-4">
<h1 class="text-3xl font-semibold font-display">Edit Self Hosting Settings</h1>
<hr>
<%= form_with model: Setting.new, url: settings_self_hosting_path, method: :patch, local: true, html: { class: "space-y-4" } do |form| %>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">Render Deploy Hook</h2>
<p class="text-gray-500">You must fill this in so your app can trigger upgrades when Maybe releases upgrades. Learn more about deploy hooks and how they work in the <%= link_to "Render documentation", "https://docs.render.com/docs/deploy-hooks", target: "_blank", rel: "noopener noreferrer", class: "text-blue-500 hover:underline" %>.</p>
<%= form.text_field :render_deploy_hook, label: "Render Deploy Hook", placeholder: "https://api.render.com/deploy/srv-xyz...", value: Setting.render_deploy_hook %>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">Auto Upgrades Setting</h2>
<p class="text-gray-500">This setting controls how often your self hosted app will update and what method it uses to do so.</p>
<div class="space-y-2">
<div class="flex items-center gap-2">
<%= form.check_box :upgrades_mode, { checked: Setting.upgrades_mode == "auto", unchecked_value: "manual" }, "auto", "manual" %>
<%= form.label :upgrades_mode, "Enable auto upgrades", class: "text-gray-900" %>
</div>
<% if Setting.upgrades_mode == "auto" %>
<div class="flex items-center gap-2">
<%= form.radio_button :upgrades_target, "release", checked: Setting.upgrades_target == "release" %>
<%= form.label :upgrades_target_release, class: Setting.upgrades_target == "release" ? "text-gray-900" : "text-gray-500" do %>
<span class="font-semibold">Latest Release (suggested)</span> - you will automatically be upgraded to the latest stable release of Maybe
<% end %>
</div>
<div class="flex items-center gap-2">
<%= form.radio_button :upgrades_target, "commit", checked: Setting.upgrades_target == "commit" %>
<%= form.label :upgrades_target_commit, class: Setting.upgrades_target == "commit" ? "text-gray-900" : "text-gray-500" do %>
<span class="font-semibold">Latest Commit</span> - you will automatically be upgraded any time the Maybe repo is updated
<% end %>
</div>
<% end %>
</div>
</section>
<div class="fixed right-5 bottom-5">
<button type="submit" class="flex items-center justify-center w-12 h-12 mb-2 bg-black rounded-full shrink-0 grow-0 hover:bg-gray-600">
<%= inline_svg_tag("icn-check.svg", class: "text-white fill-current") %>
</button>
</div>
<% end %>
</div>

View file

@ -0,0 +1,19 @@
<% if upgrade_notification %>
<% upgrade = upgrade_notification %>
<div class="bg-white space-y-4 text-right fixed bottom-10 right-10 p-5 border border-alpha-black-200 shadow-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-gray-900"><%= t(".app_upgraded", version: upgrade.to_s) %></p>
<% else %>
<p class="text-gray-900"><%= 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-gray-900'} 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>
<% end %>

View file

@ -2,6 +2,24 @@
# exit on error # exit on error
set -o errexit set -o errexit
echo "Installing gems..."
bundle install bundle install
echo "Precompiling assets..."
./bin/rails assets:precompile ./bin/rails assets:precompile
./bin/rails assets:clean ./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

View file

@ -1,88 +1,20 @@
# PostgreSQL. Versions 9.3 and up are supported.
#
# Install the pg driver:
# gem install pg
# On macOS with Homebrew:
# gem install pg -- --with-pg-config=/usr/local/bin/pg_config
# On Windows:
# gem install pg
# Choose the win32 build.
# Install PostgreSQL and put its /bin directory on your path.
#
# Configure Using Gemfile
# gem "pg"
#
default: &default default: &default
adapter: postgresql adapter: postgresql
encoding: unicode encoding: unicode
# For details on connection pooling, see Rails configuration guide
# https://guides.rubyonrails.org/configuring.html#database-pooling
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
host: <%= ENV.fetch("DB_HOST") { "127.0.0.1" } %> host: <%= ENV.fetch("DB_HOST") { "127.0.0.1" } %>
port: <%= ENV.fetch("DB_PORT") { "5432" } %> port: <%= ENV.fetch("DB_PORT") { "5432" } %>
password: <%= ENV.fetch("POSTGRES_PASSWORD") { nil } %>
user: <%= ENV.fetch("POSTGRES_USER") { nil } %> user: <%= ENV.fetch("POSTGRES_USER") { nil } %>
password: <%= ENV.fetch("POSTGRES_PASSWORD") { nil } %>
development: development:
<<: *default <<: *default
database: maybe_development database: <%= ENV.fetch("POSTGRES_DB") { "maybe_development" } %>
# The specified database role being used to connect to PostgreSQL.
# To create additional roles in PostgreSQL see `$ createuser --help`.
# When left blank, PostgreSQL will use the default role. This is
# the same name as the operating system user running Rails.
#username: maybe
# The password associated with the PostgreSQL role (username).
#password:
# Connect on a TCP socket. Omitted by default since the client uses a
# domain socket that doesn't need configuration. Windows does not have
# domain sockets, so uncomment these lines.
#host: localhost
# The TCP port the server listens on. Defaults to 5432.
# If your server runs on a different port number, change accordingly.
#port: 5432
# Schema search path. The server defaults to $user,public
#schema_search_path: myapp,sharedapp,public
# Minimum log levels, in increasing order:
# debug5, debug4, debug3, debug2, debug1,
# log, notice, warning, error, fatal, and panic
# Defaults to warning.
#min_messages: notice
# 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: test:
<<: *default <<: *default
database: maybe_test database: <%= ENV.fetch("POSTGRES_DB") { "maybe_test" } %>
# As with config/credentials.yml, you never want to store sensitive information,
# like your database password, in your source code. If your source code is
# ever seen by anyone, they now have access to your database.
#
# Instead, provide the password or a full connection URL as an environment
# variable when you boot the app. For example:
#
# DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase"
#
# If the connection URL is provided in the special DATABASE_URL environment
# variable, Rails will automatically merge its configuration values on top of
# the values provided in this file. Alternatively, you can specify a connection
# URL environment variable explicitly:
#
# production:
# url: <%= ENV["MY_APP_DATABASE_URL"] %>
#
# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database
# for a full overview on how database connection configuration can be specified.
#
production: production:
<<: *default <<: *default
database: maybe_production database: <%= ENV.fetch("POSTGRES_DB") { "maybe_production" } %>
username: maybe
password: <%= ENV["MAYBE_DATABASE_PASSWORD"] %>

View file

@ -1,9 +1,13 @@
Rails.application.configure do Rails.application.configure do
config.good_job.enable_cron = true config.good_job.enable_cron = true
if ENV["UPGRADES_ENABLED"] == "true"
config.good_job.cron = { config.good_job.cron = {
maintenance: { auto_upgrade: {
cron: "0 22 * * *", cron: "every 30 seconds",
class: "DailyExchangeRateJob" class: "AutoUpgradeJob",
description: "Check for new versions of the app and upgrade if necessary"
} }
} }
end end
end

View file

@ -0,0 +1,16 @@
module Maybe
class << self
def version
Semver.new(semver)
end
def commit_sha
`git rev-parse HEAD`.chomp
end
private
def semver
"0.1.0-alpha.1"
end
end
end

View file

@ -0,0 +1,13 @@
---
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

@ -0,0 +1,8 @@
---
en:
settings:
self_hosting:
update:
render_deploy_hook_error: Render deploy hook must be provided to enable auto
upgrades
success: Settings updated successfully.

View file

@ -20,3 +20,9 @@ en:
and account graphs.</p></br><p>The only way youll be able to add this entry and account graphs.</p></br><p>The only way youll be able to add this entry
back is by re-entering it manually via a new entry</p>" back is by re-entering it manually via a new entry</p>"
title: Delete Entry? title: Delete Entry?
shared:
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

@ -0,0 +1,10 @@
---
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

@ -5,7 +5,10 @@ Rails.application.routes.draw do
resource :session resource :session
resource :password_reset resource :password_reset
resource :password resource :password
resource :settings, only: %i[edit update]
resource :settings, only: %i[edit update] do
resource :self_hosting, only: %i[edit update], controller: "settings/self_hosting"
end
resources :transactions do resources :transactions do
match "search" => "transactions#search", on: :collection, via: [ :get, :post ], as: :search match "search" => "transactions#search", on: :collection, via: [ :get, :post ], as: :search
@ -20,6 +23,14 @@ Rails.application.routes.draw do
resources :valuations resources :valuations
end end
# For managing self-hosted upgrades and release notifications
resources :upgrades, only: [] do
member do
post :acknowledge
post :deploy
end
end
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live. # Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check get "up" => "rails/health#show", as: :rails_health_check

View file

@ -0,0 +1,15 @@
class CreateSettings < ActiveRecord::Migration[7.2]
def self.up
create_table :settings do |t|
t.string :var, null: false
t.text :value, null: true
t.timestamps
end
add_index :settings, %i[var], unique: true
end
def self.down
drop_table :settings
end
end

View file

@ -0,0 +1,9 @@
class AddLastSeenUpgradeToUser < ActiveRecord::Migration[7.2]
def change
# Self-hosted users will be prompted to upgrade to the latest commit or release.
add_column :users, :last_prompted_upgrade_commit_sha, :string
# All users will be notified when a new commit or release has successfully been deployed.
add_column :users, :last_alerted_upgrade_commit_sha, :string
end
end

14
db/schema.rb generated
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_04_04_112829) do ActiveRecord::Schema[7.2].define(version: 2024_04_11_102931) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@ -85,7 +85,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_04_112829) do
t.uuid "accountable_id" t.uuid "accountable_id"
t.decimal "balance", precision: 19, scale: 4, default: "0.0" t.decimal "balance", precision: 19, scale: 4, default: "0.0"
t.string "currency", default: "USD" t.string "currency", default: "USD"
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Account::Loan'::character varying, 'Account::Credit'::character varying, 'Account::OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Account::Loan'::character varying)::text, ('Account::Credit'::character varying)::text, ('Account::OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
t.boolean "is_active", default: true, null: false t.boolean "is_active", default: true, null: false
t.enum "status", default: "ok", null: false, enum_type: "account_status" t.enum "status", default: "ok", null: false, enum_type: "account_status"
t.jsonb "sync_warnings", default: "[]", null: false t.jsonb "sync_warnings", default: "[]", null: false
@ -200,6 +200,14 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_04_112829) do
t.index ["token"], name: "index_invite_codes_on_token", unique: true t.index ["token"], name: "index_invite_codes_on_token", unique: true
end end
create_table "settings", force: :cascade do |t|
t.string "var", null: false
t.text "value"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["var"], name: "index_settings_on_var", unique: true
end
create_table "transaction_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "transaction_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "color", default: "#6172F3", null: false t.string "color", default: "#6172F3", null: false
@ -234,6 +242,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_04_112829) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.datetime "last_login_at" t.datetime "last_login_at"
t.string "last_prompted_upgrade_commit_sha"
t.string "last_alerted_upgrade_commit_sha"
t.index ["email"], name: "index_users_on_email", unique: true t.index ["email"], name: "index_users_on_email", unique: true
t.index ["family_id"], name: "index_users_on_family_id" t.index ["family_id"], name: "index_users_on_family_id"
end end

22
docs/self-hosting.md Normal file
View file

@ -0,0 +1,22 @@
The fastest way to get your own version of Maybe running is a "one-click deploy". Below are the currently supported platforms:
## One-Click Deploys
### Render (recommended)
<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. Follow the instructions in the [Render self-hosting guide](self-hosting/render.md)
## Docker
**Estimated cost:** $5-15 per month
_Docker is not yet supported, but will be soon._
## Self hosting disclaimer
While we attempt to provide cost-effective deployment options, please remember, **self-hosting _may_ incur monthly charges on your hosting platform of choice**. While we provide cost estimates for each deployment option, it is your responsibility to manage these costs.

View file

@ -0,0 +1,70 @@
# Self Hosting Maybe on Render
Welcome to the self hosting guide for Maybe on [Render](https://render.com/)!
Render is our _recommended_ option for hosting 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)

33
lib/semver.rb Normal file
View file

@ -0,0 +1,33 @@
# Light wrapper around Gem::Version to support tag parsing
class Semver
attr_reader :version
def initialize(version_string)
@version_string = version_string
@version = Gem::Version.new(version_string)
end
def > (other)
@version > other.version
end
def < (other)
@version < other.version
end
def == (other)
@version == other.version
end
def to_s
@version_string
end
def to_release_tag
"v#{@version_string}"
end
def self.from_release_tag(tag)
new(tag.sub(/^v/, ""))
end
end

57
render.yaml Normal file
View file

@ -0,0 +1,57 @@
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"
preDeployCommand: "bundle exec rails db:migrate"
startCommand: "bundle exec rails server"
envVars:
- key: DATABASE_URL
fromDatabase:
name: maybe
property: connectionString
- key: SELF_HOSTING_ENABLED
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

@ -25,7 +25,7 @@ class RegistrationsControllerTest < ActionDispatch::IntegrationTest
end end
test "create when hosted requires an invite code" do test "create when hosted requires an invite code" do
in_hosted_app do in_invited_app do
assert_no_difference "User.count" do assert_no_difference "User.count" do
post registration_url, params: { user: { post registration_url, params: { user: {
email: "john@example.com", email: "john@example.com",
@ -54,10 +54,10 @@ class RegistrationsControllerTest < ActionDispatch::IntegrationTest
private private
def in_hosted_app def in_invited_app
ENV["HOSTED"] = "true" ENV["REQUIRE_INVITE_CODE"] = "true"
yield yield
ensure ensure
ENV["HOSTED"] = nil ENV["REQUIRE_INVITE_CODE"] = nil
end end
end end

View file

@ -0,0 +1,31 @@
require "test_helper"
class Settings::SelfHostingControllerTest < ActionDispatch::IntegrationTest
setup do
ENV["SELF_HOSTING_ENABLED"] = "true"
sign_in users(:family_admin)
end
test "cannot edit when self hosting is disabled" do
ENV["SELF_HOSTING_ENABLED"] = "false"
get edit_settings_self_hosting_url
assert :not_found
patch settings_self_hosting_url, params: { setting: { render_deploy_hook: "https://example.com" } }
assert :not_found
end
test "should get edit when self hosting is enabled" do
get edit_settings_self_hosting_url
assert_response :success
end
test "can update settings when self hosting is enabled" do
NEW_RENDER_DEPLOY_HOOK = "https://api.render.com/deploy/srv-abc123"
assert_nil Setting.render_deploy_hook
patch settings_self_hosting_url, params: { setting: { render_deploy_hook: NEW_RENDER_DEPLOY_HOOK } }
assert_equal NEW_RENDER_DEPLOY_HOOK, Setting.render_deploy_hook
end
end

View file

@ -0,0 +1,83 @@
require "test_helper"
class UpgradesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
ENV["UPGRADES_ENABLED"] = "true"
@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
ENV["UPGRADES_ENABLED"] = "false"
post "/upgrades/acknowledge/47bb430954292d2fdcc81082af731a16b9587da3"
assert_response :not_found
post "/upgrades/deploy/47bb430954292d2fdcc81082af731a16b9587da3"
assert_response :not_found
end
test "should acknowledge an upgrade prompt" 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
test "should acknowledge an upgrade alert" 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
test "should deploy an upgrade" 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
test "should rollback user state if upgrade fails" 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

View file

@ -0,0 +1,25 @@
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

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

View file

@ -0,0 +1,9 @@
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

@ -0,0 +1,88 @@
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

View file

@ -0,0 +1,235 @@
---
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