diff --git a/.env.example b/.env.example index 510658e7..9fcb7ecf 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file +# 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 diff --git a/Gemfile b/Gemfile index 083f8a91..718e0142 100644 --- a/Gemfile +++ b/Gemfile @@ -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" diff --git a/Gemfile.lock b/Gemfile.lock index 1b60a41c..fe596b3b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) @@ -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) @@ -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 diff --git a/README.md b/README.md index 3ed41481..7c3ecb81 100644 --- a/README.md +++ b/README.md @@ -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) + + +Deploy to Render + + +1. Click the button above +2. Follow the instructions in the [Render self-hosting guide](docs/self-hosting/render.md) + ## Local Development Setup ### 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! -## 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") diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f8d4686a..d37580ac 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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? diff --git a/app/controllers/concerns/invitable.rb b/app/controllers/concerns/invitable.rb new file mode 100644 index 00000000..397c7a68 --- /dev/null +++ b/app/controllers/concerns/invitable.rb @@ -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 diff --git a/app/controllers/concerns/self_hostable.rb b/app/controllers/concerns/self_hostable.rb new file mode 100644 index 00000000..1573e533 --- /dev/null +++ b/app/controllers/concerns/self_hostable.rb @@ -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 diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index f8e84f4b..d1049ff3 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -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 diff --git a/app/controllers/settings/self_hosting_controller.rb b/app/controllers/settings/self_hosting_controller.rb new file mode 100644 index 00000000..7bd71791 --- /dev/null +++ b/app/controllers/settings/self_hosting_controller.rb @@ -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 diff --git a/app/controllers/upgrades_controller.rb b/app/controllers/upgrades_controller.rb new file mode 100644 index 00000000..baf4cd8d --- /dev/null +++ b/app/controllers/upgrades_controller.rb @@ -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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 17b3be1f..c39022b5 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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" ] diff --git a/app/helpers/settings/self_hosting_helper.rb b/app/helpers/settings/self_hosting_helper.rb new file mode 100644 index 00000000..7240b433 --- /dev/null +++ b/app/helpers/settings/self_hosting_helper.rb @@ -0,0 +1,2 @@ +module Settings::SelfHostingHelper +end diff --git a/app/helpers/upgrades_helper.rb b/app/helpers/upgrades_helper.rb new file mode 100644 index 00000000..4361ae90 --- /dev/null +++ b/app/helpers/upgrades_helper.rb @@ -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 diff --git a/app/jobs/auto_upgrade_job.rb b/app/jobs/auto_upgrade_job.rb new file mode 100644 index 00000000..30c10bc7 --- /dev/null +++ b/app/jobs/auto_upgrade_job.rb @@ -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 diff --git a/app/models/concerns/providable.rb b/app/models/concerns/providable.rb index 24f82402..9b80bbf8 100644 --- a/app/models/concerns/providable.rb +++ b/app/models/concerns/providable.rb @@ -9,5 +9,9 @@ module Providable def exchange_rates_provider Provider::Synth.new end + + def git_repository_provider + Provider::Github.new + end end end diff --git a/app/models/provider/github.rb b/app/models/provider/github.rb new file mode 100644 index 00000000..ff00794c --- /dev/null +++ b/app/models/provider/github.rb @@ -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 diff --git a/app/models/setting.rb b/app/models/setting.rb new file mode 100644 index 00000000..4e73bef2 --- /dev/null +++ b/app/models/setting.rb @@ -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 diff --git a/app/models/upgrader.rb b/app/models/upgrader.rb new file mode 100644 index 00000000..605c7da8 --- /dev/null +++ b/app/models/upgrader.rb @@ -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 diff --git a/app/models/upgrader/config.rb b/app/models/upgrader/config.rb new file mode 100644 index 00000000..a78e51e7 --- /dev/null +++ b/app/models/upgrader/config.rb @@ -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 diff --git a/app/models/upgrader/deployer.rb b/app/models/upgrader/deployer.rb new file mode 100644 index 00000000..cb12992e --- /dev/null +++ b/app/models/upgrader/deployer.rb @@ -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 diff --git a/app/models/upgrader/deployer/null.rb b/app/models/upgrader/deployer/null.rb new file mode 100644 index 00000000..d4d217fa --- /dev/null +++ b/app/models/upgrader/deployer/null.rb @@ -0,0 +1,8 @@ +class Upgrader::Deployer::Null + def deploy(upgrade) + { + success: true, + message: I18n.t("upgrader.deployer.null_deployer.success_message") + } + end +end diff --git a/app/models/upgrader/deployer/render.rb b/app/models/upgrader/deployer/render.rb new file mode 100644 index 00000000..5df86857 --- /dev/null +++ b/app/models/upgrader/deployer/render.rb @@ -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 diff --git a/app/models/upgrader/provided.rb b/app/models/upgrader/provided.rb new file mode 100644 index 00000000..4b518e51 --- /dev/null +++ b/app/models/upgrader/provided.rb @@ -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 diff --git a/app/models/upgrader/upgrade.rb b/app/models/upgrader/upgrade.rb new file mode 100644 index 00000000..26b09226 --- /dev/null +++ b/app/models/upgrader/upgrade.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 97d9d362..a0bd8ada 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 64d014f9..6fa4d937 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -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") %> - Settings + General Settings + <% 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") %> + Self Host Settings + <% 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 @@ <%= turbo_frame_tag "modal" %> <%= render "shared/custom_confirm_modal" %> + <%= render "shared/upgrade_notification" %> diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index 04d99a55..bfd7289f 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -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 %> diff --git a/app/views/settings/self_hosting/edit.html.erb b/app/views/settings/self_hosting/edit.html.erb new file mode 100644 index 00000000..d721c9a1 --- /dev/null +++ b/app/views/settings/self_hosting/edit.html.erb @@ -0,0 +1,40 @@ +
+

Edit Self Hosting Settings

+
+ <%= form_with model: Setting.new, url: settings_self_hosting_path, method: :patch, local: true, html: { class: "space-y-4" } do |form| %> +
+

Render Deploy Hook

+

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" %>.

+ <%= form.text_field :render_deploy_hook, label: "Render Deploy Hook", placeholder: "https://api.render.com/deploy/srv-xyz...", value: Setting.render_deploy_hook %> +
+
+

Auto Upgrades Setting

+

This setting controls how often your self hosted app will update and what method it uses to do so.

+
+
+ <%= 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" %> +
+ <% if Setting.upgrades_mode == "auto" %> +
+ <%= 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 %> + Latest Release (suggested) - you will automatically be upgraded to the latest stable release of Maybe + <% end %> +
+
+ <%= 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 %> + Latest Commit - you will automatically be upgraded any time the Maybe repo is updated + <% end %> +
+ <% end %> +
+
+
+ +
+ <% end %> +
diff --git a/app/views/shared/_upgrade_notification.html.erb b/app/views/shared/_upgrade_notification.html.erb new file mode 100644 index 00000000..bd24268c --- /dev/null +++ b/app/views/shared/_upgrade_notification.html.erb @@ -0,0 +1,19 @@ +<% if upgrade_notification %> + <% upgrade = upgrade_notification %> +
+
+

<%= link_to upgrade.to_s, upgrade.url, class: "text-sm text-blue-500 underline hover:text-blue-700", target: "_blank" %>

+ <% if upgrade.complete? %> +

<%= t(".app_upgraded", version: upgrade.to_s) %>

+ <% else %> +

<%= t(".new_version_available") %>

+ <% end %> +
+
+ <%= 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 %> +
+
+<% end %> diff --git a/bin/render-build.sh b/bin/render-build.sh index e8834c44..5f260f64 100755 --- a/bin/render-build.sh +++ b/bin/render-build.sh @@ -2,6 +2,24 @@ # exit on error set -o errexit +echo "Installing gems..." bundle install + +echo "Precompiling assets..." ./bin/rails assets:precompile -./bin/rails assets:clean \ No newline at end of file +./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 diff --git a/config/database.yml b/config/database.yml index ac8bfe3e..221e19e8 100644 --- a/config/database.yml +++ b/config/database.yml @@ -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 adapter: postgresql 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 } %> host: <%= ENV.fetch("DB_HOST") { "127.0.0.1" } %> port: <%= ENV.fetch("DB_PORT") { "5432" } %> - password: <%= ENV.fetch("POSTGRES_PASSWORD") { nil } %> user: <%= ENV.fetch("POSTGRES_USER") { nil } %> + password: <%= ENV.fetch("POSTGRES_PASSWORD") { nil } %> development: <<: *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: <<: *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: <<: *default - database: maybe_production - username: maybe - password: <%= ENV["MAYBE_DATABASE_PASSWORD"] %> + database: <%= ENV.fetch("POSTGRES_DB") { "maybe_production" } %> diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb index 8e675fd9..9841d0e9 100644 --- a/config/initializers/good_job.rb +++ b/config/initializers/good_job.rb @@ -1,9 +1,13 @@ Rails.application.configure do config.good_job.enable_cron = true - config.good_job.cron = { - maintenance: { - cron: "0 22 * * *", - class: "DailyExchangeRateJob" + + if ENV["UPGRADES_ENABLED"] == "true" + config.good_job.cron = { + auto_upgrade: { + cron: "every 30 seconds", + class: "AutoUpgradeJob", + description: "Check for new versions of the app and upgrade if necessary" + } } - } + end end diff --git a/config/initializers/version.rb b/config/initializers/version.rb new file mode 100644 index 00000000..b445b179 --- /dev/null +++ b/config/initializers/version.rb @@ -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 diff --git a/config/locales/models/upgrader/en.yml b/config/locales/models/upgrader/en.yml new file mode 100644 index 00000000..6a655717 --- /dev/null +++ b/config/locales/models/upgrader/en.yml @@ -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 diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml new file mode 100644 index 00000000..2b980511 --- /dev/null +++ b/config/locales/views/settings/en.yml @@ -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. diff --git a/config/locales/views/shared/en.yml b/config/locales/views/shared/en.yml index f3aaaba1..9f9d8088 100644 --- a/config/locales/views/shared/en.yml +++ b/config/locales/views/shared/en.yml @@ -20,3 +20,9 @@ en: and account graphs.


The only way you’ll be able to add this entry back is by re-entering it manually via a new 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 diff --git a/config/locales/views/upgrades/en.yml b/config/locales/views/upgrades/en.yml new file mode 100644 index 00000000..b6dd9708 --- /dev/null +++ b/config/locales/views/upgrades/en.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 7d451cfb..18d18f59 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,7 +5,10 @@ Rails.application.routes.draw do resource :session resource :password_reset 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 match "search" => "transactions#search", on: :collection, via: [ :get, :post ], as: :search @@ -20,6 +23,14 @@ Rails.application.routes.draw do resources :valuations 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. # 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 diff --git a/db/migrate/20240410183531_create_settings.rb b/db/migrate/20240410183531_create_settings.rb new file mode 100644 index 00000000..a442ee2e --- /dev/null +++ b/db/migrate/20240410183531_create_settings.rb @@ -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 diff --git a/db/migrate/20240411102931_add_last_seen_upgrade_to_user.rb b/db/migrate/20240411102931_add_last_seen_upgrade_to_user.rb new file mode 100644 index 00000000..68094e21 --- /dev/null +++ b/db/migrate/20240411102931_add_last_seen_upgrade_to_user.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 0a1105c6..ad15f1b2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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 enable_extension "pgcrypto" enable_extension "plpgsql" @@ -85,7 +85,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_04_112829) do t.uuid "accountable_id" t.decimal "balance", precision: 19, scale: 4, default: "0.0" 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.enum "status", default: "ok", null: false, enum_type: "account_status" 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 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| t.string "name", 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 "updated_at", null: false 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 ["family_id"], name: "index_users_on_family_id" end diff --git a/docs/self-hosting.md b/docs/self-hosting.md new file mode 100644 index 00000000..d3943205 --- /dev/null +++ b/docs/self-hosting.md @@ -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) + + +Deploy to Render + + +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. diff --git a/docs/self-hosting/render.md b/docs/self-hosting/render.md new file mode 100644 index 00000000..b04d11b8 --- /dev/null +++ b/docs/self-hosting/render.md @@ -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 + + +Deploy to Render + + +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) diff --git a/lib/semver.rb b/lib/semver.rb new file mode 100644 index 00000000..ce65c623 --- /dev/null +++ b/lib/semver.rb @@ -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 diff --git a/render.yaml b/render.yaml new file mode 100644 index 00000000..1c404712 --- /dev/null +++ b/render.yaml @@ -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 diff --git a/test/controllers/registrations_controller_test.rb b/test/controllers/registrations_controller_test.rb index bd4b262a..2d546e83 100644 --- a/test/controllers/registrations_controller_test.rb +++ b/test/controllers/registrations_controller_test.rb @@ -25,7 +25,7 @@ class RegistrationsControllerTest < ActionDispatch::IntegrationTest end test "create when hosted requires an invite code" do - in_hosted_app do + in_invited_app do assert_no_difference "User.count" do post registration_url, params: { user: { email: "john@example.com", @@ -54,10 +54,10 @@ class RegistrationsControllerTest < ActionDispatch::IntegrationTest private - def in_hosted_app - ENV["HOSTED"] = "true" + def in_invited_app + ENV["REQUIRE_INVITE_CODE"] = "true" yield ensure - ENV["HOSTED"] = nil + ENV["REQUIRE_INVITE_CODE"] = nil end end diff --git a/test/controllers/settings/self_hosting_controller_test.rb b/test/controllers/settings/self_hosting_controller_test.rb new file mode 100644 index 00000000..0a899a56 --- /dev/null +++ b/test/controllers/settings/self_hosting_controller_test.rb @@ -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 diff --git a/test/controllers/upgrades_controller_test.rb b/test/controllers/upgrades_controller_test.rb new file mode 100644 index 00000000..a70b21b4 --- /dev/null +++ b/test/controllers/upgrades_controller_test.rb @@ -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 diff --git a/test/interfaces/git_repository_provider_interface_test.rb b/test/interfaces/git_repository_provider_interface_test.rb new file mode 100644 index 00000000..2b0dff8e --- /dev/null +++ b/test/interfaces/git_repository_provider_interface_test.rb @@ -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 diff --git a/test/jobs/auto_upgrade_job_test.rb b/test/jobs/auto_upgrade_job_test.rb new file mode 100644 index 00000000..d68aa2a1 --- /dev/null +++ b/test/jobs/auto_upgrade_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class AutoUpgradeJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/provider/github_test.rb b/test/models/provider/github_test.rb new file mode 100644 index 00000000..6e425bf2 --- /dev/null +++ b/test/models/provider/github_test.rb @@ -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 diff --git a/test/models/upgrader_test.rb b/test/models/upgrader_test.rb new file mode 100644 index 00000000..6bad83ff --- /dev/null +++ b/test/models/upgrader_test.rb @@ -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 diff --git a/test/vcr_cassettes/git_repository_provider/fetch_latest_upgrade_candidates.yml b/test/vcr_cassettes/git_repository_provider/fetch_latest_upgrade_candidates.yml new file mode 100644 index 00000000..2e44df83 --- /dev/null +++ b/test/vcr_cassettes/git_repository_provider/fetch_latest_upgrade_candidates.yml @@ -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: + - ; rel="next", ; + 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