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

@ -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