1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-05 05:25:24 +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

@ -1,5 +1,5 @@
class ApplicationController < ActionController::Base
include Authentication
include Authentication, Invitable, SelfHostable
include Pagy::Backend
before_action :sync_accounts
@ -11,11 +11,6 @@ class ApplicationController < ActionController::Base
private
def hosted_app?
ENV["HOSTED"] == "true"
end
helper_method :hosted_app?
def sync_accounts
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"
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
@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 }
end
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" ]
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
Provider::Synth.new
end
def git_repository_provider
Provider::Github.new
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
password_salt&.last(10)
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

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">
<%= 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") %>
<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 %>
<%= 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") %>
@ -86,5 +92,6 @@
</div>
<%= turbo_frame_tag "modal" %>
<%= render "shared/custom_confirm_modal" %>
<%= render "shared/upgrade_notification" %>
</body>
</html>

View file

@ -1,19 +1,13 @@
<%
header_title t(".title")
%>
<%= form_with model: @user, url: registration_path do |form| %>
<%= auth_messages form %>
<%= 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_confirmation, autocomplete: "new-password", required: "required", label: true %>
<% if hosted_app? %>
<% if invite_code_required? %>
<%= form.password_field :invite_code, required: "required", label: true %>
<% end %>
<%= form.submit %>
<% 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 %>