├── .gitignore ├── .travis.yml ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin └── divergence ├── divergence.gemspec ├── generators └── files │ ├── callbacks.rb │ ├── config.rb │ └── config.ru ├── lib ├── divergence.rb ├── divergence │ ├── application.rb │ ├── cache_manager.rb │ ├── config.rb │ ├── git_manager.rb │ ├── helpers.rb │ ├── request_parser.rb │ ├── respond.rb │ ├── version.rb │ └── webhook.rb └── rack_ssl_hack.rb ├── log └── .gitkeep ├── public └── 404.html └── test ├── config.rb ├── config_test.rb ├── git_test.rb ├── request_test.rb ├── test_helper.rb └── webhook_test.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | log/*.log 19 | .DS_Store 20 | test/app_root 21 | test/cache_root -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.3 4 | - 1.8.7 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gem "rake" 3 | 4 | # Specify your gem's dependencies in divergence.gemspec 5 | gemspec 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2012 LayerVault Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Divergence 2 | 3 | Map subdomains to git branches for switching live codebases on the fly. It's a Rack application that acts as a HTTP proxy between you and your web application for rapid testing. 4 | 5 | ## Installation 6 | 7 | First, you will need to install the gem: 8 | 9 | ``` 10 | gem install divergence 11 | ``` 12 | 13 | Then, since divergence is a rackup application, you will need to initialize it somewhere by running: 14 | 15 | ``` 16 | divergence init 17 | ``` 18 | 19 | This copies all of the necessary files into the current folder for you. 20 | 21 | ### DNS 22 | 23 | You have to do this manually for now. Hopefully in the future, Divergence will be able to automatically handle the DNS setup. All you need to do is create an A record with a wildcard subdomain that points to your testing server IP. 24 | 25 | ## Config 26 | 27 | All configuration happens in `config/config.rb`. You must set the git repository root and the application root before using divergence. 28 | 29 | You will probably want divergence to take over port 80 on your testing server, so you may have to update the forwarding host/port. Note, this is the address where your actual web application can be reached. 30 | 31 | A sample config could look like this: 32 | 33 | ``` ruby 34 | Divergence::Application.configure do |config| 35 | config.git_path = "/path/to/git_root" 36 | config.app_path = "/path/to/app_root" 37 | config.cache_path = "/path/to/cache_root" 38 | 39 | config.forward_host = 'localhost' 40 | config.forward_port = 80 41 | 42 | config.callbacks :after_swap do 43 | restart_passenger 44 | end 45 | 46 | config.callbacks :after_cache, :after_webhook do 47 | bundle_install :path => "vendor/bundle" 48 | end 49 | 50 | config.callbacks :on_branch_discover do |subdomain| 51 | case subdomain 52 | when "release-1" 53 | "test_branch" 54 | when "release-2" 55 | "other_branch" 56 | end 57 | end 58 | end 59 | ``` 60 | 61 | ### Callbacks 62 | 63 | Divergence lets you hook into various callbacks throughout the entire process. These are defined in `config/callbacks.rb`. Most callbacks automatically change the current working directory for you in order to make modifications as simple as possible. 64 | 65 | The available callbacks are: 66 | 67 | * before_cache 68 | * Active dir: git repository 69 | * after_cache 70 | * Active dir: cached folder path 71 | * before_swap 72 | * Active dir: cached folder path 73 | * after_swap 74 | * Active dir: application 75 | * before_pull 76 | * Active dir: git repository 77 | * Only executes if a git pull is required 78 | * after_pull 79 | * Active dir: git repository 80 | * Only executes if the git pull succeeds 81 | * on_pull_error 82 | * Active dir: git repository 83 | * Executes if there is a problem checking out and pulling a branch 84 | * on_branch_discover 85 | * Active dir: git repository 86 | * Executes if the subdomain has a dash in the name. The subdomain name is passed to the callback in the options hash. 87 | * If the callback returns nil, Divergence will try to auto-detect the branch name, otherwise it will use whatever you return. 88 | * before_webook 89 | * Active dir: git repository 90 | * Runs before a branch is updated via webhooks. 91 | * after_webhook 92 | * Active dir: cached folder path 93 | * Runs after a webhook update completes 94 | 95 | There are also some built-in helper methods that are available inside callbacks. They are: 96 | 97 | * bundle_install 98 | * Recommended - after_cache, after_webhook 99 | * Options: 100 | * :deployment => boolean 101 | * :path => string 102 | * restart_passenger 103 | * Recommended - after_swap 104 | 105 | ### Github Service Hook 106 | 107 | You can automatically keep the currently active branch up to date by using a Github service hook. In your repository on Github, go to Admin -> Service Hooks -> WebHook URLs. Add the url: 108 | 109 | ``` 110 | http://divergence.[your domain].com/update 111 | ``` 112 | 113 | Now, whenever you push code to your repository, divergence will automatically know and will update accordingly. 114 | 115 | ## Running 116 | 117 | To start divergence, simply run in the divergence directory you initialized: 118 | 119 | ``` 120 | divergence start 121 | ``` 122 | 123 | This will start up divergence on port 9292 by default. If you'd like divergence to run on a different port, you can specify that as well: 124 | 125 | ``` 126 | divergence start --port=88 127 | ``` 128 | 129 | There is also a `--dev` flag that will run divergence in the foreground instead of daemonizing it. 130 | 131 | ### Port 80 132 | 133 | On many systems, running on port 80 requires special permissions. If you try starting divergence, but get the error `TCPServer Error: Permission denied - bind(2)`, then you will need to run divergence with sudo (or as root). If you use RVM to manage multiple Ruby versions, then you can use `rvmsudo` instead. 134 | 135 | Make sure, if you're using Git over SSH, that you have your repository's host added to your known hosts file for the root user. 136 | 137 | ### HTTPS 138 | 139 | Divergence currently does not support HTTPS on its own; however, you can still use HTTPS in combination with a load balancer if you enable SSL termination. 140 | 141 | ### Invalid URL Characters 142 | 143 | Git supports a much wider range of characters for branch names than URLs support. To get around this limitation, simply replace any invalid URL character with a `-` and Divergence will find the branch you're looking for automatically. 144 | 145 | ## TODO 146 | 147 | * Handle simultaneous users better 148 | * Built-in HTTPS support 149 | * Helpers for more frameworks 150 | 151 | ## Contributing 152 | 153 | 1. Fork it 154 | 2. Create your feature branch (`git checkout -b my-new-feature`) 155 | 3. Commit your changes (`git commit -am 'Add some feature'`) 156 | 4. Push to the branch (`git push origin my-new-feature`) 157 | 5. Create new Pull Request 158 | 159 | ## Authors 160 | 161 | * [Ryan LeFevre](http://meltingice.net) - Project Creator 162 | 163 | ## License 164 | 165 | Licensed under the Apache 2.0 License. See LICENSE for details. 166 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | task :default => [:test] 5 | 6 | task :docs do 7 | `rdoc --main lib/divergence.rb lib` 8 | end 9 | 10 | namespace :test do 11 | Rake::TestTask.new(:rack) do |t| 12 | t.libs << "test" 13 | t.pattern = "test/*_test.rb" 14 | t.verbose = true 15 | end 16 | end 17 | 18 | task :test do 19 | Rake::Task["test:rack"].invoke 20 | end 21 | -------------------------------------------------------------------------------- /bin/divergence: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "thor" 4 | require "thor/group" 5 | 6 | module CLI 7 | class Init < Thor::Group 8 | include ::Thor::Actions 9 | 10 | desc "Initializes a divergence application into the current directory" 11 | 12 | def self.source_root 13 | ::File.expand_path('../../generators/files', __FILE__) 14 | end 15 | 16 | def create_directories 17 | empty_directory "config" 18 | empty_directory "log" 19 | empty_directory "tmp" 20 | end 21 | 22 | def copy_templates 23 | template "config.rb", "config/config.rb" 24 | template "callbacks.rb", "config/callbacks.rb" 25 | template "config.ru", "config.ru" 26 | end 27 | end 28 | end 29 | 30 | module CLI 31 | class Base < Thor 32 | register CLI::Init, "init", "init", "Initializes a divergence application into the current directory" 33 | 34 | desc "start", "Start divergence" 35 | method_options :port => :number, :dev => :boolean 36 | def start 37 | 38 | cmd = 'rackup' 39 | cmd << " -p #{options[:port]}" if options[:port] 40 | cmd << " -D" unless options[:dev] 41 | cmd << " -P tmp/rack.pid" 42 | 43 | puts "Divergence started" 44 | exec cmd 45 | end 46 | 47 | desc "stop", "Stop divergence" 48 | def stop 49 | `kill -9 $(cat tmp/rack.pid) && rm tmp/rack.pid` 50 | puts "Divergence halted" 51 | end 52 | end 53 | end 54 | 55 | CLI::Base.start -------------------------------------------------------------------------------- /divergence.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'divergence/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = "divergence" 8 | gem.version = Divergence::VERSION 9 | gem.authors = ["Ryan LeFevre"] 10 | gem.email = ["ryan@layervault.com"] 11 | gem.description = "Map subdomains to git branches for switching live codebases on the fly. It's a Rack application that acts as a HTTP proxy between you and your web application for rapid testing." 12 | gem.summary = "Map virtual host subdomains to git branches for testing" 13 | gem.homepage = "http://cosmos.layervault.com/divergence.html" 14 | 15 | gem.files = `git ls-files`.split($/) 16 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 17 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 18 | gem.require_paths = ["lib"] 19 | 20 | gem.add_dependency "rack" 21 | gem.add_dependency "rack-proxy" 22 | gem.add_dependency "thor" 23 | gem.add_dependency "json" 24 | gem.add_development_dependency "rack-test" 25 | end 26 | -------------------------------------------------------------------------------- /generators/files/callbacks.rb: -------------------------------------------------------------------------------- 1 | Divergence::Application.configure do |config| 2 | config.callbacks :after_swap do 3 | # Run anything after the swap finishes 4 | # 5 | # after_swap changes to the app directory for you, so 6 | # you can simply run any commands you want. There are 7 | # some built-in helpers too. 8 | end 9 | end -------------------------------------------------------------------------------- /generators/files/config.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../callbacks', __FILE__) 2 | 3 | Divergence::Application.configure do |config| 4 | config.git_path = nil # Change this to the git repository path 5 | config.app_path = nil # and this to your application's path. 6 | config.cache_path = nil # This should be an empty directory 7 | 8 | # The number of branches to cache for quick switching. If you're 9 | # switching around between many branches frequently, you might 10 | # want to raise this. Keep in mind that each cached branch will 11 | # have it's own Passenger instance, so don't get too carried away. 12 | # config.cache_num = 5 13 | 14 | # Where should we proxy this request to? Normally you can leave 15 | # the host as 'localhost', but if you are using virtual hosts in 16 | # your web server setup, you may need to be more specific. You 17 | # will probably want Divergence to take over port 80 as well, 18 | # so update your web application to run on a different port. 19 | config.forward_host = 'localhost' 20 | config.forward_port = 80 21 | end -------------------------------------------------------------------------------- /generators/files/config.ru: -------------------------------------------------------------------------------- 1 | require 'divergence' 2 | require ::File.expand_path('../config/config', __FILE__) 3 | 4 | run Divergence::Application.new() -------------------------------------------------------------------------------- /lib/divergence.rb: -------------------------------------------------------------------------------- 1 | require "rack/proxy" 2 | require "json" 3 | require "logger" 4 | require "fileutils" 5 | 6 | require "rack_ssl_hack" 7 | require "divergence/version" 8 | require "divergence/config" 9 | require "divergence/application" 10 | require "divergence/git_manager" 11 | require "divergence/cache_manager" 12 | require "divergence/helpers" 13 | require "divergence/request_parser" 14 | require "divergence/respond" 15 | require "divergence/webhook" 16 | 17 | module Divergence 18 | class Application < Rack::Proxy 19 | @@config = Configuration.new 20 | @@log = Logger.new('./log/app.log') 21 | 22 | def self.configure(&block) 23 | block.call(@@config) 24 | end 25 | 26 | def self.log 27 | @@log 28 | end 29 | 30 | def self.config 31 | @@config 32 | end 33 | 34 | def initialize 35 | config.ok? 36 | 37 | @git = GitManager.new(config.git_path) 38 | @cache = CacheManager.new(config.cache_path, config.cache_num) 39 | @active_branch = "" 40 | end 41 | 42 | def config 43 | @@config 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/divergence/application.rb: -------------------------------------------------------------------------------- 1 | module Divergence 2 | # Responsible for managing the cache folders and swapping 3 | # the codebases around. 4 | class Application < Rack::Proxy 5 | # Prepares the filesystem for loading up a branch 6 | def prepare(branch, opts = {}) 7 | return nil if branch == @active_branch 8 | 9 | unless @cache.is_cached?(branch) 10 | @cache.add branch, @git.switch(branch) 11 | end 12 | 13 | @cache.path(branch) 14 | end 15 | 16 | # Links the application directory to the given path, 17 | # which is always a cache directory in our case. 18 | def link!(path) 19 | Application.log.info "Link: #{path} -> #{config.app_path}" 20 | 21 | config.callback :before_swap, path 22 | FileUtils.rm config.app_path if File.exists?(config.app_path) 23 | FileUtils.ln_s path, config.app_path, :force => true 24 | config.callback :after_swap, config.app_path 25 | end 26 | end 27 | end -------------------------------------------------------------------------------- /lib/divergence/cache_manager.rb: -------------------------------------------------------------------------------- 1 | module Divergence 2 | class CacheManager 3 | def initialize(cache_path, cache_num) 4 | @cache_path = cache_path 5 | @cache_num = cache_num 6 | 7 | Dir.chdir @cache_path do 8 | @cached_branches = Dir['*/'].map {|dir| dir.gsub('/', '')} 9 | end 10 | end 11 | 12 | def is_cached?(branch) 13 | @cached_branches.include?(branch) 14 | end 15 | 16 | def add(branch, src_path) 17 | Application.log.info "Caching: #{branch} from #{src_path}" 18 | 19 | prune_cache! 20 | 21 | Application.config.callback :before_cache, src_path, :branch => branch 22 | 23 | FileUtils.mkdir_p path(branch) 24 | sync branch, src_path 25 | @cached_branches.push branch 26 | 27 | Application.config.callback :after_cache, path(branch), :branch => branch 28 | end 29 | 30 | def sync(branch, src_path) 31 | `rsync -a --delete --exclude .git --exclude .gitignore #{src_path}/ #{path(branch)}` 32 | end 33 | 34 | def path(branch) 35 | "#{@cache_path}/#{branch}" 36 | end 37 | 38 | private 39 | 40 | # Delete the oldest cached branches to make room for new 41 | def prune_cache! 42 | Dir.chdir @cache_path do 43 | branches = Dir.glob('*/') 44 | return if branches.nil? or branches.length <= @cache_num 45 | 46 | branches \ 47 | .sort_by {|f| File.mtime(f)}[@cache_num..-1] \ 48 | .each do|dir| 49 | FileUtils.rm_rf(dir) 50 | @cached_branches.delete(dir.gsub('/', '')) 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/divergence/config.rb: -------------------------------------------------------------------------------- 1 | module Divergence 2 | class Configuration 3 | include Enumerable 4 | 5 | attr_accessor :app_path, :git_path, :cache_path 6 | attr_accessor :cache_num 7 | attr_accessor :forward_host, :forward_port 8 | 9 | def initialize 10 | @git_path = nil 11 | @app_path = nil 12 | @cache_path = nil 13 | 14 | @cache_num = 5 15 | 16 | @forward_host = 'localhost' 17 | @forward_port = 80 18 | 19 | @callback_store = {} 20 | @helpers = Divergence::Helpers.new(self) 21 | end 22 | 23 | def ok? 24 | [:git_path, :app_path, :cache_path].each do |path| 25 | if instance_variable_get("@#{path}").nil? 26 | raise "Configure #{path} before running" 27 | end 28 | end 29 | 30 | unless File.exists?(@git_path) 31 | raise "Configured git path not found: #{@git_path}" 32 | end 33 | 34 | unless File.exists?(@cache_path) 35 | FileUtils.mkdir_p @cache_path 36 | end 37 | end 38 | 39 | # Lets a user define a callback for a specific event 40 | def callbacks(*names, &block) 41 | names.each do |name| 42 | unless @callback_store.has_key?(name) 43 | @callback_store[name] = [] 44 | end 45 | 46 | @callback_store[name].push block 47 | end 48 | end 49 | 50 | def callback(name, run_path=nil, args = {}) 51 | return unless @callback_store.has_key?(name) 52 | 53 | if run_path.nil? or !File.exists?(run_path) 54 | run_path = Dir.pwd 55 | end 56 | 57 | Application.log.debug "Execute callback: #{name.to_s} in #{run_path}" 58 | 59 | Dir.chdir run_path do 60 | @callback_store[name].each do |cb| 61 | @helpers.execute cb, args 62 | end 63 | end 64 | end 65 | 66 | def each(&block) 67 | instance_variables.each do |key| 68 | if block_given? 69 | block.call key, instance_variable_get(key) 70 | else 71 | yield instance_variable_get(key) 72 | end 73 | end 74 | end 75 | end 76 | end -------------------------------------------------------------------------------- /lib/divergence/git_manager.rb: -------------------------------------------------------------------------------- 1 | module Divergence 2 | # Manages the configured Git repository 3 | class GitManager 4 | attr_reader :current_branch 5 | 6 | def initialize(git_path) 7 | @git_path = git_path 8 | @log = Logger.new('./log/git.log') 9 | @current_branch = current_branch 10 | end 11 | 12 | def switch(branch, force=false) 13 | return @git_path if is_current?(branch) and !force 14 | 15 | pull branch 16 | return @git_path 17 | end 18 | 19 | # Since underscores are technically not allowed in URLs, 20 | # but they are allowed in Git branch names, we have to do 21 | # some magic to possibly convert dashes to underscores 22 | # so we can load the right branch. 23 | def discover(branch) 24 | return branch if is_branch?(branch) 25 | 26 | resp = Application.config.callback :on_branch_discover, @git_path, branch 27 | 28 | unless resp.nil? 29 | return resp 30 | end 31 | 32 | local_search = "^" + branch.gsub(/-/, ".") + "$" 33 | remote_search = "^remotes/origin/(" + branch.gsub(/-/, ".") + ")$" 34 | local_r = Regexp.new(local_search, Regexp::IGNORECASE) 35 | remote_r = Regexp.new(remote_search, Regexp::IGNORECASE) 36 | 37 | git('branch -a').split("\n").each do |b| 38 | b = b.gsub('*', '').strip 39 | 40 | return b if local_r.match(b) 41 | if remote_r.match(b) 42 | return remote_r.match(b)[1] 43 | end 44 | end 45 | 46 | raise "Unable to automatically detect branch. Given = #{branch}" 47 | end 48 | 49 | def is_current?(branch) 50 | @current_branch.to_s == branch 51 | end 52 | 53 | private 54 | 55 | def current_branch 56 | git('branch -a').split("\n").each do |b| 57 | if b[0, 2] == '* ' 58 | return b.gsub('* ', '').strip 59 | end 60 | end 61 | end 62 | 63 | def is_branch?(branch) 64 | # This is fast, but only works on locally checked out branches 65 | `git --git-dir #{@git_path}/.git show-ref --verify --quiet 'refs/heads/#{branch}'` 66 | return true if $?.exitstatus == 0 67 | 68 | # This is slow and will only get called for remote branches. 69 | result = `git --git-dir #{@git_path}/.git ls-remote --heads origin 'refs/heads/#{branch}'` 70 | return result.strip.length != 0 71 | end 72 | 73 | def pull(branch) 74 | if checkout(branch) 75 | Application.config.callback :before_pull, @git_path 76 | git "pull origin #{branch}" 77 | Application.config.callback :after_pull, @git_path 78 | else 79 | Application.config.callback :on_pull_error, @git_path 80 | 81 | return false 82 | end 83 | end 84 | 85 | def checkout(branch) 86 | fetch 87 | reset 88 | 89 | begin 90 | git "checkout -f #{branch}" 91 | @current_branch = branch 92 | rescue 93 | return false 94 | end 95 | end 96 | 97 | def reset 98 | git 'reset --hard' 99 | end 100 | 101 | # Fetch all remote branch information 102 | def fetch 103 | git :fetch 104 | end 105 | 106 | def git(cmd) 107 | @log.info "git --work-tree #{@git_path} --git-dir #{@git_path}/.git #{cmd.to_s}" 108 | out = `git --work-tree #{@git_path} --git-dir #{@git_path}/.git #{cmd.to_s} 2>&1` 109 | 110 | if $?.exitstatus != 0 111 | Application.log.error "git --work-tree #{@git_path} --git-dir #{@git_path}/.git #{cmd.to_s} failed" 112 | Application.log.error out 113 | end 114 | 115 | return out 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/divergence/helpers.rb: -------------------------------------------------------------------------------- 1 | module Divergence 2 | class Helpers 3 | def initialize(config) 4 | @config = config 5 | end 6 | 7 | def execute(block, opts={}) 8 | self.instance_exec opts, &block 9 | end 10 | 11 | private 12 | 13 | def bundle_install(opts={}) 14 | Application.log.debug "bundle install" 15 | 16 | begin 17 | cmd = 'bundle install' 18 | cmd << ' --deployment' if opts[:deployment] 19 | cmd << " --path #{opts[:path]}" if opts[:path] 20 | cmd << ' --without development test' 21 | result = `#{cmd}` 22 | Application.log.debug result 23 | rescue 24 | Application.log.error "bundle install failed!" 25 | Application.log.error e.message 26 | end 27 | end 28 | 29 | def restart_passenger 30 | Application.log.debug "Restarting passenger..." 31 | 32 | begin 33 | unless File.exists? 'tmp' 34 | FileUtils.mkdir_p 'tmp' 35 | end 36 | 37 | FileUtils.touch 'tmp/restart.txt' 38 | rescue 39 | end 40 | end 41 | 42 | def restart_unicorn 43 | Application.log.debug "Restarting unicorn..." 44 | begin 45 | unicorn_pid_file = File.join(config.app_path, "tmp/pids/unicorn.pid") 46 | cmd = "cat #{unicorn_pid_file} | xargs kill -USR2" 47 | result = `#{cmd}` 48 | Application.log.debug result 49 | rescue 50 | end 51 | end 52 | end 53 | end -------------------------------------------------------------------------------- /lib/divergence/request_parser.rb: -------------------------------------------------------------------------------- 1 | module Divergence 2 | class RequestParser 3 | def initialize(env) 4 | @req = Rack::Request.new(env) 5 | end 6 | 7 | def raw 8 | @req 9 | end 10 | 11 | def is_webhook? 12 | subdomain == "divergence" and 13 | @req.env['PATH_INFO'] == "/update" and 14 | @req.post? 15 | end 16 | 17 | def host_parts 18 | @req.host.split(".") 19 | end 20 | 21 | def has_subdomain? 22 | host_parts.length > 2 23 | end 24 | 25 | def subdomain 26 | if has_subdomain? 27 | host_parts.shift 28 | else 29 | nil 30 | end 31 | end 32 | 33 | def branch 34 | if has_subdomain? 35 | branch = subdomain 36 | 37 | if branch['-'] 38 | @git.discover(branch) 39 | else 40 | branch 41 | end 42 | else 43 | nil 44 | end 45 | end 46 | 47 | def method_missing(meth, *args, &block) 48 | raw.send(meth, *args) 49 | end 50 | end 51 | end -------------------------------------------------------------------------------- /lib/divergence/respond.rb: -------------------------------------------------------------------------------- 1 | module Divergence 2 | class Application < Rack::Proxy 3 | # The main entry point for the application. This is called 4 | # by Rack. 5 | def call(env) 6 | @req = RequestParser.new(env) 7 | 8 | # First, lets find out what subdomain/git branch 9 | # we're dealing with (if any). 10 | unless @req.has_subdomain? 11 | # No subdomain, simply proxy the request. 12 | return proxy(env) 13 | end 14 | 15 | # Handle webhooks from Github for updating the current 16 | # branch if necessary. 17 | if @req.is_webhook? 18 | return handle_webhook 19 | end 20 | 21 | # Lets get down to business. 22 | begin 23 | # Get the proper branch name using a touch of magic 24 | branch = @git.discover(@req.subdomain) 25 | 26 | # Prepare the branch and cache if needed 27 | path = prepare(branch) 28 | 29 | # If we're requesting a different branch than the 30 | # one currently loaded, we'll need to link it to 31 | # the application directory. 32 | link!(path) unless path.nil? 33 | 34 | @active_branch = branch 35 | rescue Exception => e 36 | Application.log.error e.message 37 | return error!(branch) 38 | end 39 | 40 | # We're finished, pass the request through. 41 | proxy(env) 42 | end 43 | 44 | private 45 | 46 | def proxy(env) 47 | fix_environment!(env) 48 | 49 | status, header, body = perform_request(env) 50 | 51 | # This is super weird. Not sure why there is a status 52 | # header coming through, but Rack::Lint complains about 53 | # it, so we just remove it. I think this might be coming 54 | # from Cloudfront (if you use it). 55 | if header.has_key?('Status') 56 | header.delete 'Status' 57 | end 58 | 59 | [status, header, body] 60 | end 61 | 62 | # Sets the forwarding host for the request. This is where 63 | # the proxy comes in. 64 | def fix_environment!(env) 65 | env["HTTP_HOST"] = "#{config.forward_host}:#{config.forward_port}" 66 | end 67 | 68 | def error!(branch) 69 | Application.log.error "Branch #{branch} does not exist" 70 | Application.log.error @req.raw 71 | 72 | public_path = File.expand_path('../../../public', __FILE__) 73 | file = File.open("#{public_path}/404.html", "r") 74 | contents = file.read 75 | file.close 76 | 77 | [404, {"Content-Type" => "text/html"}, [contents]] 78 | end 79 | end 80 | end -------------------------------------------------------------------------------- /lib/divergence/version.rb: -------------------------------------------------------------------------------- 1 | module Divergence 2 | VERSION = "0.2.0" 3 | end 4 | -------------------------------------------------------------------------------- /lib/divergence/webhook.rb: -------------------------------------------------------------------------------- 1 | module Divergence 2 | class Application 3 | def handle_webhook 4 | hook = JSON.parse(@req['payload']) 5 | branch = hook["ref"].split("/").last.strip 6 | 7 | Application.log.info "Webhook: received for #{branch} branch" 8 | 9 | # If the webhook is for the currently active branch, 10 | # then we perform a pull and a swap. 11 | if @cache.is_cached?(branch) 12 | Application.log.info "Webhook: updating #{branch}" 13 | 14 | git_path = @git.switch branch, :force => true 15 | 16 | config.callback :before_webhook, git_path, :branch => branch 17 | @cache.sync branch, git_path 18 | config.callback :after_webhook, @cache.path(branch), :branch => branch 19 | 20 | ok 21 | else 22 | Application.log.info "Webhook: ignoring #{branch}" 23 | ignore 24 | end 25 | end 26 | 27 | def ok 28 | [200, {"Content-Type" => "text/html"}, ["OK"]] 29 | end 30 | 31 | def ignore 32 | [200, {"Content-Type" => "text/html"}, ["IGNORE"]] 33 | end 34 | end 35 | end -------------------------------------------------------------------------------- /lib/rack_ssl_hack.rb: -------------------------------------------------------------------------------- 1 | # Monkey patching sucks, but Rack seems to think we have 2 | # HTTPS even with SSL termination enabled. This force 3 | # disables it until HTTPS is built-in to divergence. 4 | module Rack 5 | class HttpStreamingResponse 6 | def use_ssl 7 | false 8 | end 9 | 10 | def use_ssl=(v) 11 | self.instance_variable_set(:@use_ssl, false) 12 | end 13 | end 14 | end -------------------------------------------------------------------------------- /log/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/layervault/divergence/1ff4021d204afdf9fa1c672f405f6d1d0c435c1d/log/.gitkeep -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Branch not found - Divergence 5 | 40 | 41 | 42 |
43 |
Looks like you've lost your way. You have diverged upon a branch that does not exist.
44 |
45 | 46 | 47 | -------------------------------------------------------------------------------- /test/config.rb: -------------------------------------------------------------------------------- 1 | Divergence::Application.configure do |config| 2 | config.git_path = "./test/git_root" 3 | config.app_path = "./test/app_root" 4 | config.cache_path = "./test/cache_root" 5 | 6 | config.forward_host = 'localhost' 7 | config.forward_port = 80 8 | 9 | config.callbacks :after_swap, :after_webhook do 10 | # Run anything after the swap finishes 11 | end 12 | end -------------------------------------------------------------------------------- /test/config_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ConfigureTest < Test::Unit::TestCase 4 | def test_config 5 | git_path = File.expand_path('../git_root', __FILE__) 6 | app_path = File.expand_path('../app_root', __FILE__) 7 | cache_path = File.expand_path('../cache_root', __FILE__) 8 | 9 | Divergence::Application.configure do |config| 10 | config.git_path = git_path 11 | config.app_path = app_path 12 | config.cache_path = cache_path 13 | 14 | config.forward_host = 'localhost' 15 | config.forward_port = 80 16 | end 17 | 18 | assert app.config.app_path, app_path 19 | assert app.config.git_path, git_path 20 | assert app.config.cache_path, cache_path 21 | end 22 | end -------------------------------------------------------------------------------- /test/git_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class GitTest < Test::Unit::TestCase 4 | def test_ignore 5 | mock_get 'master.example.com' 6 | 7 | assert_equal "master", active_branch 8 | 9 | # and again... 10 | mock_get 'master.example.com' 11 | assert_equal 'master', active_branch 12 | end 13 | 14 | def test_switch_branch 15 | mock_get 'branch1.example.com' 16 | assert_equal 'branch1', active_branch 17 | end 18 | 19 | def test_branch_discover 20 | mock_get 'branch-1.example.com' 21 | assert_equal 'branch_1', active_branch 22 | 23 | mock_get 'branch-with-complex-name-1.example.com' 24 | assert_equal 'branch_with_complex_name-1', active_branch 25 | end 26 | 27 | def test_dirty_switch 28 | mock_get "master.example.com" 29 | 30 | File.open 'test/git_root/test.txt', 'a' do |f| 31 | f.write 'modifying this' 32 | end 33 | 34 | mock_get 'branch1.example.com' 35 | 36 | assert_equal 'branch1', active_branch 37 | end 38 | 39 | def test_swap 40 | mock_get "branch1.example.com" 41 | 42 | assert File.exists? 'test/app_root/test.txt' 43 | 44 | assert_equal "branch1", active_branch 45 | end 46 | end -------------------------------------------------------------------------------- /test/request_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class RequestTest < Test::Unit::TestCase 4 | def test_has_subdomain 5 | set_mock_request "master.example.com" 6 | assert app.req.has_subdomain? 7 | end 8 | 9 | def test_no_subdomain 10 | set_mock_request "example.com" 11 | assert !app.req.has_subdomain? 12 | end 13 | 14 | def test_branch 15 | set_mock_request "master.example.com" 16 | assert_equal app.req.branch, "master" 17 | end 18 | 19 | def test_missing_branch 20 | status, _, _ = mock_get "idontexist.example.com" 21 | assert_equal status, 404 22 | end 23 | end -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "test/unit" 2 | require "rack" 3 | require "rack/test" 4 | 5 | require "./lib/divergence" 6 | require "./test/config" 7 | 8 | #require 'debugger'; debugger 9 | 10 | Test::Unit::TestCase.class_eval do 11 | include Rack::Test::Methods 12 | end 13 | 14 | class Test::Unit::TestCase 15 | def app 16 | unless @d 17 | @d = Divergence::Application.new 18 | end 19 | 20 | @d 21 | end 22 | 23 | def active_branch 24 | file = File.open 'test/app_root/test.txt' 25 | contents = file.read.strip 26 | file.close 27 | contents 28 | end 29 | 30 | # We have to rewrite the host constant in rack-test 31 | # in order to set a host with a subdomain. Gross. 32 | def set_request_addr(addr) 33 | old = $VERBOSE 34 | $VERBOSE = nil 35 | Rack::Test::DEFAULT_HOST.replace addr 36 | $VERBOSE = old 37 | end 38 | 39 | def set_mock_request(addr, opts={}) 40 | req = Rack::MockRequest.env_for "http://#{addr}", opts 41 | app.req = Divergence::RequestParser.new(req) 42 | end 43 | 44 | def mock_get(addr, params={}) 45 | env = Rack::MockRequest.env_for "http://#{addr}", 46 | :params => params 47 | 48 | app.call env 49 | end 50 | 51 | def mock_post(addr, params={}) 52 | env = Rack::MockRequest.env_for "http://#{addr}", 53 | :method => :post, 54 | :params => params 55 | 56 | app.call env 57 | end 58 | 59 | def mock_webhook(branch) 60 | mock_post "divergence.example.com/update", 61 | :payload => JSON.generate({ 62 | :ref => "refs/heads/#{branch.to_s}" 63 | }) 64 | end 65 | end 66 | 67 | module Divergence 68 | class Application < Rack::Proxy 69 | attr_accessor :req, :active_branch 70 | 71 | def perform_request(env) 72 | [200, {"Content-Type" => "text/html"}, ["Ohai"]] 73 | end 74 | end 75 | end -------------------------------------------------------------------------------- /test/webhook_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class WebhookTest < Test::Unit::TestCase 4 | def test_detect 5 | mock_get "master.example.com" 6 | status, _, body = mock_webhook :master 7 | 8 | assert_equal 200, status 9 | assert_equal ["OK"], body 10 | end 11 | 12 | def test_ignore 13 | status, _, body = mock_webhook 'webhook-ignore' 14 | 15 | assert_equal 200, status 16 | assert_equal ["IGNORE"], body 17 | end 18 | end --------------------------------------------------------------------------------