Skip to content

Commit

Permalink
Add zero-config self hosting on Render (#612)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
zachgoll authored Apr 13, 2024
1 parent 2bbf120 commit 5aca2ff
Show file tree
Hide file tree
Showing 53 changed files with 1,356 additions and 111 deletions.
33 changes: 31 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.
HOSTED=false
# 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
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
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ gem "ransack"
gem "stackprof"
gem "sentry-ruby"
gem "sentry-rails"
gem "rails-settings-cached"
gem "octokit"

# Other
gem "bcrypt", "~> 3.1.7"
Expand Down
12 changes: 12 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,10 @@ GEM
racc (~> 1.4)
nokogiri (1.16.3-x86_64-linux)
racc (~> 1.4)
octokit (8.1.0)
base64
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (8.0.2)
parallel (1.24.0)
parser (3.3.0.5)
Expand Down Expand Up @@ -291,6 +295,9 @@ GEM
rails-i18n (7.0.8)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
rails-settings-cached (2.9.4)
activerecord (>= 5.0.0)
railties (>= 5.0.0)
rainbow (3.1.1)
rake (13.2.1)
ransack (4.1.1)
Expand Down Expand Up @@ -349,6 +356,9 @@ GEM
ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
selenium-webdriver (4.19.0)
base64 (~> 0.2)
rexml (~> 3.2, >= 3.2.5)
Expand Down Expand Up @@ -435,11 +445,13 @@ DEPENDENCIES
letter_opener
lucide-rails!
mocha
octokit
pagy
pg (~> 1.5)
propshaft
puma (>= 5.0)
rails!
rails-settings-cached
ransack
redis (>= 4.0.1)
rubocop-rails-omakase
Expand Down
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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

### Requirements
Expand Down Expand Up @@ -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!

## 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](https://repobeats.axiom.co/api/embed/7866c9790deba0baf63ca1688b209130b306ea4e.svg "Repobeats analytics image")
Expand Down
7 changes: 1 addition & 6 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class ApplicationController < ActionController::Base
include Authentication
include Authentication, Invitable, SelfHostable
include Pagy::Backend

before_action :sync_accounts
Expand All @@ -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?

Expand Down
12 changes: 12 additions & 0 deletions app/controllers/concerns/invitable.rb
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions app/controllers/concerns/self_hostable.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion app/controllers/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions app/controllers/settings/self_hosting_controller.rb
Original file line number Diff line number Diff line change
@@ -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
56 changes: 56 additions & 0 deletions app/controllers/upgrades_controller.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 0 additions & 1 deletion app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ def sidebar_modal(&block)
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" ]
Expand Down
2 changes: 2 additions & 0 deletions app/helpers/settings/self_hosting_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module Settings::SelfHostingHelper
end
13 changes: 13 additions & 0 deletions app/helpers/upgrades_helper.rb
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions app/jobs/auto_upgrade_job.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions app/models/concerns/providable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,9 @@ module Providable
def exchange_rates_provider
Provider::Synth.new
end

def git_repository_provider
Provider::Github.new
end
end
end
47 changes: 47 additions & 0 deletions app/models/provider/github.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 5aca2ff

Please sign in to comment.