├── test-vm ├── share │ └── .gitkeep └── manifests │ └── base.pp ├── features ├── templates │ ├── project │ │ ├── Procfile.erb │ │ ├── Gemfile.erb │ │ ├── Capfile │ │ └── Capfile.erb │ └── gem │ │ ├── gemspec.erb │ │ └── binary.erb ├── deploying-projects.feature ├── bundling-gems.feature ├── managing-processes.feature ├── support │ ├── server.rb │ └── project.rb ├── setting-environment-variables.feature └── steps │ └── capistrano_steps.rb ├── .travis.yml ├── lib ├── recap │ ├── version.rb │ ├── recipes │ │ ├── static.rb │ │ ├── rails.rb │ │ └── ruby.rb │ ├── support │ │ ├── templates │ │ │ └── Capfile.erb │ │ ├── compatibility.rb │ │ ├── shell_command.rb │ │ ├── environment.rb │ │ ├── namespace.rb │ │ ├── cli.rb │ │ └── capistrano_extensions.rb │ ├── tasks.rb │ ├── tasks │ │ ├── ruby.rb │ │ ├── multistage.rb │ │ ├── rails.rb │ │ ├── bundler.rb │ │ ├── preflight.rb │ │ ├── foreman.rb │ │ ├── bootstrap.rb │ │ ├── env.rb │ │ └── deploy.rb │ └── recipes.rb └── recap.rb ├── bin └── recap ├── Gemfile ├── .gitignore ├── spec ├── spec_helper.rb ├── tasks │ ├── bootstrap_spec.rb │ ├── ruby_spec.rb │ ├── env_spec.rb │ ├── bundler_spec.rb │ ├── rails_spec.rb │ ├── foreman_spec.rb │ └── deploy_spec.rb └── models │ ├── cli_spec.rb │ ├── shell_command_spec.rb │ ├── capistrano_extensions_spec.rb │ └── environment_spec.rb ├── upstart └── process.conf.erb ├── LICENSE ├── recap.gemspec ├── Rakefile ├── Vagrantfile └── README.md /test-vm/share/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /features/templates/project/Procfile.erb: -------------------------------------------------------------------------------- 1 | <%= name %>: <%= command %> -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | rvm: 2 | - 1.8.7 3 | - 1.9.2 4 | - 1.9.3 5 | - 2.0.0 -------------------------------------------------------------------------------- /lib/recap/version.rb: -------------------------------------------------------------------------------- 1 | module Recap 2 | VERSION = '1.2.2' 3 | end 4 | -------------------------------------------------------------------------------- /bin/recap: -------------------------------------------------------------------------------- 1 | require 'recap/support/cli' 2 | Recap::Support::CLI.start 3 | 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in recap.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | .vagrant 4 | Gemfile.lock 5 | pkg/* 6 | /test-vm/share/* 7 | capistrano.log 8 | doc -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'recap' 2 | 3 | Recap::Support::Namespace.default_config = nil 4 | 5 | RSpec.configure do |config| 6 | config.mock_with :mocha 7 | end -------------------------------------------------------------------------------- /lib/recap/recipes/static.rb: -------------------------------------------------------------------------------- 1 | # Require `recap/recipes/static` if you are deploying a simple application which doesn't use 2 | # Rails, Bundler or Foreman. 3 | require 'recap/tasks/deploy' 4 | -------------------------------------------------------------------------------- /lib/recap/support/templates/Capfile.erb: -------------------------------------------------------------------------------- 1 | require 'recap/recipes/<%= recipe %>' 2 | 3 | set :application, '<%= name %>' 4 | set :repository, '<%= repository %>' 5 | 6 | server '<%= server %>', :app -------------------------------------------------------------------------------- /features/templates/project/Gemfile.erb: -------------------------------------------------------------------------------- 1 | source :rubygems 2 | <% if foreman %> 3 | gem 'foreman' 4 | <% end %> 5 | <% gems.each do |gem, version| %> 6 | gem '<%= gem %>', :git => '/recap/share/gems/<%= gem %>', :tag => '<%= version %>' 7 | <% end %> -------------------------------------------------------------------------------- /lib/recap/recipes/rails.rb: -------------------------------------------------------------------------------- 1 | # The `recap/recipes/rails` builds on the [ruby](ruby.html) 2 | # recipe, which provides support for both `bundler` and `foreman`. 3 | require 'recap/recipes/ruby' 4 | 5 | # It adds to this with a number of rails specific tasks. See the 6 | # [rails tasks](../tasks/rails.html) documentation for more information 7 | # about the rails support. 8 | require 'recap/tasks/rails' 9 | -------------------------------------------------------------------------------- /upstart/process.conf.erb: -------------------------------------------------------------------------------- 1 | start on starting <%= app %>-<%= name %> 2 | stop on stopping <%= app %>-<%= name %> 3 | 4 | respawn 5 | 6 | setuid <%= user %> 7 | setgid <%= user %> 8 | 9 | exec /bin/bash -l -c 'cd <%= engine.root %>; export PORT=<%= port %>;<% engine.env.each_pair do |var,env| %> export <%= var.upcase %>=<%= shell_quote(env) %>; <% end %> exec <%= process.command %> >> <%= log %>/<%=name%>-<%=num%>.log 2>&1' 10 | -------------------------------------------------------------------------------- /features/templates/gem/gemspec.erb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | Gem::Specification.new do |gem| 4 | gem.authors = ["Tom Ward"] 5 | gem.email = ["tom@popdog.net"] 6 | gem.description = %q{<%= gem %> <%= version %>} 7 | gem.summary = %q{<%= gem %> <%= version %>} 8 | gem.executables = ['<%= gem %>'] 9 | gem.name = "<%= gem %>" 10 | gem.version = "<%= version %>" 11 | end 12 | -------------------------------------------------------------------------------- /test-vm/manifests/base.pp: -------------------------------------------------------------------------------- 1 | group { 'puppet': 2 | ensure => 'present' 3 | } 4 | 5 | package { ['git-core', 'curl']: 6 | ensure => present 7 | } 8 | 9 | package { 'bundler': 10 | provider => gem, 11 | ensure => '1.1.rc.7' 12 | } 13 | 14 | package { 'foreman': 15 | provider => gem, 16 | ensure => present 17 | } 18 | 19 | file { '/usr/local/bin/ruby': 20 | ensure => 'link', 21 | target => '/opt/ruby/bin/ruby', 22 | } 23 | -------------------------------------------------------------------------------- /lib/recap/recipes/ruby.rb: -------------------------------------------------------------------------------- 1 | # Require `recap/recipes/ruby` in your `Capfile` to use the default recap recipies for deploying a 2 | # Ruby application. 3 | require 'recap/tasks/deploy' 4 | require 'recap/tasks/ruby' 5 | 6 | # If your application uses Bundler, `bundle install` will be run automatically when deploying 7 | # any changes to your `Gemfile`. 8 | require 'recap/tasks/bundler' 9 | 10 | # If your application uses Foreman, recap will use that to stop, start and restart your 11 | # application processes. 12 | require 'recap/tasks/foreman' -------------------------------------------------------------------------------- /features/deploying-projects.feature: -------------------------------------------------------------------------------- 1 | Feature: Deploying and rolling back deployments 2 | 3 | Scenario: Deploying for the first time 4 | 5 | Given a new project and a bootstrapped server 6 | When I run "cap deploy:setup deploy" 7 | Then the project should be deployed 8 | 9 | Scenario: Deploying after changes 10 | 11 | Given a deployed project 12 | When I commit changes to the project 13 | And I run "cap deploy" 14 | Then the latest version of the project should be deployed 15 | 16 | Scenario: Rolling back to the previous version 17 | 18 | Given a deployed project 19 | When I commit and deploy changes to the project 20 | And I run "cap deploy:rollback" 21 | Then the previous project version should be deployed -------------------------------------------------------------------------------- /spec/tasks/bootstrap_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'recap/tasks/bootstrap' 3 | 4 | describe Recap::Tasks::Bootstrap do 5 | let :config do 6 | Capistrano::Configuration.new 7 | end 8 | 9 | let :namespace do 10 | config.bootstrap 11 | end 12 | 13 | let :commands do 14 | sequence('commands') 15 | end 16 | 17 | before do 18 | Recap::Tasks::Bootstrap.load_into(config) 19 | end 20 | 21 | describe 'Tasks' do 22 | describe 'bootstrap' do 23 | it 'runs bootsrap:application and bootstrap:user tasks' do 24 | namespace.expects(:application).in_sequence(commands) 25 | namespace.expects(:user).in_sequence(commands) 26 | config.find_and_execute_task('bootstrap') 27 | end 28 | end 29 | end 30 | end -------------------------------------------------------------------------------- /features/bundling-gems.feature: -------------------------------------------------------------------------------- 1 | Feature: Bundling gems in ruby projects 2 | 3 | Scenario: Deploying a project with a Gemfile 4 | 5 | Given a new ruby project and a bootstrapped server 6 | And a bundle requiring version "1.0" of "example-gem" 7 | When I run "cap deploy:setup deploy" 8 | Then the project should be deployed 9 | And the deployed project should include version "1.0" of "example-gem" 10 | 11 | Scenario: Updating a project bundle 12 | 13 | Given a new ruby project and a bootstrapped server 14 | And a bundle requiring version "1.0" of "example-gem" 15 | And I run "cap deploy:setup deploy" 16 | When I update the bundle to require version "1.1" of "example-gem" 17 | And I run "cap deploy" 18 | Then the deployed project should include version "1.1" of "example-gem" 19 | -------------------------------------------------------------------------------- /lib/recap/support/compatibility.rb: -------------------------------------------------------------------------------- 1 | # `recap` isn't intended to be compatible with tasks (such as those within the `bundler` 2 | # or `whenever` projects) that are built on the original capistrano deployment recipes. At times 3 | # though there are tasks that would work, but for some missing (and redundant) settings. 4 | # 5 | # Including this recipe adds these legacy settings, but provides no guarantee that original tasks 6 | # will work. Many are based on assumptions about the deployment layout that no longer hold true. 7 | 8 | module Recap::Support::Compatibility 9 | extend Recap::Support::Namespace 10 | 11 | # As `git` to manages releases, all deployments are placed directly in the `deploy_to` folder. The 12 | # `current_path` is always this directory (no symlinking required). 13 | _cset(:current_path) { deploy_to } 14 | end 15 | -------------------------------------------------------------------------------- /features/managing-processes.feature: -------------------------------------------------------------------------------- 1 | Feature: Managing processes with foreman 2 | 3 | Scenario: Running an application process 4 | Given a new ruby project and a bootstrapped server 5 | And the project has an application process defined in a Procfile 6 | When I run "cap deploy:setup deploy" 7 | Then the project should own the running application process 8 | 9 | Scenario: Running processes can read environment variables 10 | Given a new ruby project and a bootstrapped server 11 | And the project has an application process defined in a Procfile 12 | And the variable "MONSTER" is set to "tricorn" 13 | When I run "cap deploy:setup deploy" 14 | And I wait for the server to start 15 | Then the variable "MONSTER" should be set to "tricorn" 16 | Then the running application process should know that "MONSTER" is set to "tricorn" -------------------------------------------------------------------------------- /features/templates/project/Capfile: -------------------------------------------------------------------------------- 1 | require '<%= recap_require %>' 2 | 3 | # To connect to the vagrant VM we need to set up a few non-standard parameters, including the 4 | # vagrant SSH port and private key 5 | 6 | set :user, 'vagrant' 7 | 8 | ssh_options[:port] = 2222 9 | ssh_options[:keys] = ['<%= project.private_key_path %>'] 10 | 11 | server '127.0.0.1', :web 12 | 13 | # Each project has its own location shared between the host machine and the VM 14 | 15 | set :application, '<%= project.name %>' 16 | set :repository, '/recap/share/<%= project.name %>' 17 | 18 | # Finally, to ensure tests don't fail if deployments are made within a second of each other 19 | # which they can do when automated like this, we use a finer-grained release tag 20 | 21 | set(:release_tag) { Time.now.utc.strftime("%Y%m%d%H%M%S%L") } 22 | set(:release_matcher) { /\A[0-9]{17}\Z/ } -------------------------------------------------------------------------------- /features/templates/project/Capfile.erb: -------------------------------------------------------------------------------- 1 | require '<%= recap_require %>' 2 | 3 | # To connect to the vagrant VM we need to set up a few non-standard parameters, including the 4 | # vagrant SSH port and private key 5 | 6 | set :user, 'vagrant' 7 | 8 | ssh_options[:port] = 2222 9 | ssh_options[:keys] = ['<%= project.private_key_path %>'] 10 | 11 | server '127.0.0.1', :app 12 | 13 | # Each project has its own location shared between the host machine and the VM 14 | 15 | set :application, '<%= project.name %>' 16 | set :repository, '/recap/share/projects/<%= project.name %>' 17 | 18 | # Finally, to ensure tests don't fail if deployments are made within a second of each other 19 | # which they can do when automated like this, we use a finer-grained release tag 20 | 21 | set(:release_tag) { Time.now.utc.strftime("%Y%m%d%H%M%S%L") } 22 | set(:release_matcher) { /\A[0-9]{17}\Z/ } -------------------------------------------------------------------------------- /lib/recap/tasks.rb: -------------------------------------------------------------------------------- 1 | # Recap provides a number of capistrano tasks to aid with deployment. The core functionality 2 | # is found in the [tasks for deployment](tasks/deploy.html) and those for 3 | # [altering environment variables](tasks/env.html). 4 | # 5 | # Supporting these are [preflight checks](tasks/preflight.html) to ensure servers and 6 | # users are correctly setup, and the [bootstrap tasks](tasks/bootstrap.html) that help 7 | # do this setting up. 8 | # 9 | # In addition, there are extensions for [bundler](tasks/bundler.html), 10 | # [foreman](tasks/foreman.html), and [rails](tasks/rails.html) that add extra 11 | # functionality to the standard deploy. 12 | # 13 | # Finally, while there's no explicit support, it's still very easy to use recap for 14 | # [multistage deployments](tasks/multistage.html). 15 | 16 | require 'recap' 17 | 18 | module Recap::Tasks 19 | end -------------------------------------------------------------------------------- /lib/recap/tasks/ruby.rb: -------------------------------------------------------------------------------- 1 | module Recap::Tasks::Ruby 2 | extend Recap::Support::Namespace 3 | 4 | namespace :ruby do 5 | _cset(:skip_rails_recipe_not_used_warning, false) 6 | 7 | task :preflight do 8 | if exit_code("grep rails #{deploy_to}/Gemfile") == "0" 9 | unless skip_rails_recipe_not_used_warning || Recap::Tasks.const_defined?(:Rails) 10 | logger.important %{ 11 | Warning: it looks like you're using the recap ruby recipe on a rails project. 12 | This will prevent some rails specific features such as asset compilation and 13 | database migrations from working correctly. 14 | To fix this, require 'recap/recipes/rails' from within your Capfile. To 15 | suppress this warning, set :skip_rails_recipe_not_used_warning to true. 16 | } 17 | end 18 | end 19 | end 20 | 21 | after "preflight:check", "ruby:preflight" 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/models/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Recap::Support::CLI do 4 | subject { Recap::Support::CLI.new } 5 | 6 | describe "#setup" do 7 | it 'determines the git repository URL' do 8 | Recap::Support::ShellCommand.stubs(:execute).with('git config --get remote.origin.url').returns(%{git@github.com:freerange/recap.git}) 9 | subject.stubs(:template) 10 | subject.setup 11 | subject.repository.should eql('git@github.com:freerange/recap.git') 12 | end 13 | 14 | it 'handles exception when no git repository present and uses ' do 15 | Recap::Support::ShellCommand.stubs(:execute).with('git config --get remote.origin.url').raises 16 | subject.stubs(:template) 17 | subject.expects(:warn) 18 | lambda { subject.setup }.should_not raise_error 19 | subject.repository.should eql('') 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/recap/support/shell_command.rb: -------------------------------------------------------------------------------- 1 | require 'open4' 2 | 3 | module Recap::Support 4 | class ShellCommand 5 | def self.execute(*commands) 6 | output, error = "", "" 7 | commands.each do |command| 8 | status = Open4::popen4(command) do |pid, stdin, stdout, stderr| 9 | output, error = stdout.read, stderr.read 10 | end 11 | unless status.success? 12 | message = [ 13 | "Executing shell command failed.", 14 | " Command: #{command}", 15 | " Status: #{status.exitstatus}", 16 | " Message: #{error}" 17 | ].join("\n") 18 | raise message 19 | end 20 | end 21 | output 22 | end 23 | 24 | def self.execute_interactive(command) 25 | unless system(command) 26 | message = [ 27 | "Executing shell command failed.", 28 | " Command: #{command}", 29 | " Status: #{$?.exitstatus}" 30 | ].join("\n") 31 | raise message 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /features/templates/gem/binary.erb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'webrick' 3 | 4 | GEM_VERSION = "<%= version %>" 5 | 6 | class Server 7 | class Action < WEBrick::HTTPServlet::AbstractServlet 8 | def do_GET(request, response) 9 | response.status = 200 10 | response['Content-Type'] = "text/plain" 11 | response.body = body 12 | end 13 | end 14 | 15 | class Version < Action 16 | def body 17 | GEM_VERSION 18 | end 19 | end 20 | 21 | class Env < Action 22 | def body 23 | ENV.keys.sort.map {|k| "#{k}=#{ENV[k]}" }.join("\n") 24 | end 25 | end 26 | 27 | def self.start 28 | server = WEBrick::HTTPServer.new(:Port => 3500) 29 | server.mount "/env", Env 30 | server.mount "/version", Version 31 | server.start 32 | end 33 | end 34 | 35 | trap("SIGINT") { exit! } 36 | trap("TERM") { exit! } 37 | 38 | case ARGV[0] 39 | when "--version" then puts GEM_VERSION 40 | when "--env" then ENV.keys.sort.each {|k| puts "#{k}=#{ENV[k]}"} 41 | when "--server" then Server.start 42 | else puts "Unknown option" 43 | end -------------------------------------------------------------------------------- /lib/recap/recipes.rb: -------------------------------------------------------------------------------- 1 | # There are three main recipes, defined in [recap/recipes/static.rb](recipes/static.html), 2 | # [recap/recipes/ruby.rb](recipes/ruby.html) and [recap/recipes/rails.rb](recipes/rails.html) 3 | # that include tasks for static, ruby-based and rails sites respectively. One of these should be 4 | # required at the top of your `Capfile`. 5 | # 6 | # The static recipe includes all the main deployment behaviour. It provides everything you 7 | # should need to push static content up to one or more servers, as well as the ability to 8 | # rollback to a previous release if you make a mistake. 9 | # 10 | # The ruby recipe builds on this with support for `bundler`, to automatically install any 11 | # bundled gems. It also includes `foreman` support, starting and restarting processes 12 | # defined in a `Procfile`. 13 | # 14 | # The rails recipe includes all the above, and adds automatic database migration and 15 | # asset compilation to each deploy. 16 | # 17 | # To swap between each of these, simply change the top line of your `Capfile` to require 18 | # the one you want. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2011 by Tom Ward 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /lib/recap/tasks/multistage.rb: -------------------------------------------------------------------------------- 1 | # Recap doesn't yet provide any specific tools for multistage deployments 2 | # (deploying to different servers for `production`, `staging`, `qa`, etc.), 3 | # but you can still easily support them. 4 | # 5 | # The easiest way is to define your own task for each environment, and 6 | # declare servers and other configuration within those tasks. For example, 7 | # a `Capfile` targetting both a `staging` and `production` environment 8 | # might look like this: 9 | # 10 | #
11 | # require 'recap/recipes/rails'
12 | #
13 | # set :application, 'blanche'
14 | # set :repository, 'git@github.com:tomafro/blanche'
15 | #
16 | # task :staging do
17 | #   server 'staging.example.com', :app
18 | # end
19 | #
20 | # task :production do
21 | #   set :branch, 'production'
22 | #   server 'production.example.com', :app
23 | # end
24 | # 
25 | # 26 | # The two environments deploy to different servers, and the production 27 | # environment deploys the `production` branch of code, rather than 28 | # the default (which is `master`). 29 | # 30 | # To run tasks against each environment, simply call the environment 31 | # task first, i.e. `cap staging deploy`, or `cap production bootstrap`. -------------------------------------------------------------------------------- /recap.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "recap/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "recap" 7 | s.version = Recap::VERSION 8 | s.authors = ["Tom Ward"] 9 | s.email = ["tom@popdog.net"] 10 | s.homepage = "http://gofreerange.com/recap" 11 | s.summary = %q{GIT based deployment recipes for Capistrano} 12 | s.description = %q{GIT based deployment recipes for Capistrano} 13 | 14 | s.files = `git ls-files`.split("\n") 15 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 16 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 17 | s.require_paths = ["lib"] 18 | 19 | s.add_dependency('capistrano', '~>2.9') 20 | s.add_dependency('thor') 21 | s.add_dependency('open4') 22 | s.add_development_dependency('rake', '~>0.9.2') 23 | s.add_development_dependency('fl-rocco', '~>1.0.0') 24 | s.add_development_dependency('rspec', '~>2.13.0') 25 | s.add_development_dependency('mocha', '~>0.10.0') 26 | s.add_development_dependency('cucumber', '~>1.1.4') 27 | s.add_development_dependency('faker', '~>1.0.1') 28 | end 29 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rocco/tasks' 3 | require 'rspec/core/rake_task' 4 | 5 | RECAP_DOCS_HOST = ENV['RECAP_DOCS_HOST'] || 'gofreerange.com' 6 | 7 | desc 'build docs' 8 | task :doc do 9 | FileUtils.cd('lib') do 10 | files = Dir['**/*.rb'] 11 | files.each do |source_file| 12 | rocco = Rocco.new(source_file, files.to_a, {:stylesheet => "https://#{RECAP_DOCS_HOST}/stylesheets/rocco.css"}) 13 | dest_file = '../doc/' + source_file.sub(Regexp.new("#{File.extname(source_file)}$"), '.html') 14 | FileUtils.mkdir_p(File.dirname(dest_file)) 15 | File.open(dest_file, 'wb') { |fd| fd.write(rocco.to_html) } 16 | end 17 | end 18 | File.open('doc/index.html', 'w') do |f| 19 | f.write <<-EOS 20 | 21 | EOS 22 | end 23 | end 24 | 25 | desc 'publish docs' 26 | task :publish do 27 | path = "/home/freerange/docs/recap" 28 | system %{ssh #{RECAP_DOCS_HOST} "sudo rm -fr #{path} && mkdir -p #{path}" && scp -r doc/* #{RECAP_DOCS_HOST}:#{path}} 29 | end 30 | 31 | RSpec::Core::RakeTask.new(:spec) do |t| 32 | t.pattern = "spec/**/*_spec.rb" 33 | t.rspec_opts = "-fn --color" 34 | end 35 | 36 | task :default => :spec 37 | -------------------------------------------------------------------------------- /lib/recap/support/environment.rb: -------------------------------------------------------------------------------- 1 | module Recap::Support 2 | 3 | # This class is used to manipulate environment variables on the remote server. 4 | # You should not need to use it directly; you are probably looking for the 5 | # [env](../tasks/env.html) tasks instead. 6 | 7 | class Environment 8 | def initialize(variables = {}) 9 | @variables = variables 10 | end 11 | 12 | def get(name) 13 | @variables[name] 14 | end 15 | 16 | def set(name, value) 17 | if value.nil? || value.empty? 18 | @variables.delete(name) 19 | else 20 | @variables[name] = value 21 | end 22 | end 23 | 24 | def set_string(string) 25 | if string =~ /\A([A-Za-z0-9_]+)=(.*)\z/ 26 | set $1, $2 27 | end 28 | end 29 | 30 | def empty? 31 | @variables.empty? 32 | end 33 | 34 | def merge(hash) 35 | hash.each {|k, v| set(k, v)} 36 | end 37 | 38 | def each(&block) 39 | @variables.sort.each(&block) 40 | end 41 | 42 | def include?(key) 43 | @variables.include?(key) 44 | end 45 | 46 | def to_s 47 | @variables.keys.sort.map do |key| 48 | key + "=" + @variables[key] + "\n" if @variables[key] 49 | end.compact.join 50 | end 51 | 52 | class << self 53 | def from_string(string) 54 | string.split(/[\n\r]/).inject(new) do |env, line| 55 | env.set_string(line) 56 | env 57 | end 58 | end 59 | end 60 | end 61 | end -------------------------------------------------------------------------------- /features/support/server.rb: -------------------------------------------------------------------------------- 1 | require 'net/ssh' 2 | require 'tempfile' 3 | 4 | module ServerSupport 5 | def server 6 | @server ||= Server.instance 7 | end 8 | 9 | class Server 10 | class << self 11 | def instance 12 | Server.new 13 | end 14 | end 15 | 16 | def private_key_path 17 | ssh_config[:keys].first 18 | end 19 | 20 | def has_user?(name) 21 | test? "id -u #{name}" 22 | end 23 | 24 | def has_group?(name) 25 | test? "id -g #{name}" 26 | end 27 | 28 | def has_directory?(path) 29 | test? "[ -d #{path} ]" 30 | end 31 | 32 | def has_file?(path) 33 | test? "[ -f #{path} ]" 34 | end 35 | 36 | def test?(command) 37 | run(command) 38 | run('echo $?').to_i == 0 39 | end 40 | 41 | def run(command) 42 | stdout = '' 43 | ssh.exec!("PATH=/opt/ruby/bin:$PATH #{command}") do |channel, stream, data| 44 | stdout << data if stream == :stdout 45 | end 46 | stdout 47 | end 48 | 49 | def ssh_config 50 | Net::SSH.configuration_for('default', ssh_config_file.path) 51 | end 52 | 53 | private 54 | 55 | def ssh 56 | @ssh ||= Net::SSH.start 'default', nil, config: ssh_config_file.path 57 | end 58 | 59 | def ssh_config_file 60 | @config_file ||= begin 61 | file = Tempfile.new('ssh-config') 62 | `vagrant ssh-config > #{file.path}` 63 | file 64 | end 65 | end 66 | end 67 | end 68 | 69 | World(ServerSupport) 70 | -------------------------------------------------------------------------------- /lib/recap/support/namespace.rb: -------------------------------------------------------------------------------- 1 | require 'capistrano' 2 | require 'recap/support/capistrano_extensions' 3 | 4 | # This module is used to capture the definition of capistrano tasks, which makes it 5 | # easier to test the behaviour of specific tasks without loading everything. If you 6 | # are writing tests for a collection of tasks, you should put those tasks in a module 7 | # and extend that module with `Recap::Support::Namespace. 8 | # 9 | # You can look at some of the existing tasks (such as [env](../tasks/env.html)) and 10 | # its corresponding specs for an example of this in practice. 11 | # 12 | # You should not need to use this module directly when using recap to deploy. 13 | 14 | module Recap::Support::Namespace 15 | def self.default_config 16 | @default_config 17 | end 18 | 19 | def self.default_config=(config) 20 | @default_config = config 21 | end 22 | 23 | if Capistrano::Configuration.instance 24 | self.default_config = Capistrano::Configuration.instance(:must_exist) 25 | end 26 | 27 | def capistrano_definitions 28 | @capistrano_definitions ||= [] 29 | end 30 | 31 | def namespace(name, &block) 32 | capistrano_definitions << Proc.new do 33 | namespace name do 34 | instance_eval(&block) 35 | end 36 | end 37 | 38 | load_into(Recap::Support::Namespace.default_config) if Recap::Support::Namespace.default_config 39 | end 40 | 41 | def load_into(configuration) 42 | configuration.extend(self) 43 | capistrano_definitions.each do |definition| 44 | configuration.load(&definition) 45 | end 46 | end 47 | end -------------------------------------------------------------------------------- /lib/recap/support/cli.rb: -------------------------------------------------------------------------------- 1 | require 'thor' 2 | require 'recap' 3 | require 'recap/support/shell_command' 4 | 5 | module Recap::Support 6 | 7 | # Recap provides a simple command-line tool (`recap`) to generate a `Capfile` in your 8 | # project. 9 | 10 | class CLI < Thor 11 | include Thor::Actions 12 | 13 | attr_accessor :name, :repository, :recipe, :server 14 | 15 | def self.source_root 16 | File.expand_path("../templates", __FILE__) 17 | end 18 | 19 | desc 'setup', 'Setup basic capistrano recipes, e.g: recap setup' 20 | method_option :name 21 | method_option :repository 22 | method_option :server 23 | method_option :recipe, :type => 'string', :banner => 'static|ruby|rails' 24 | 25 | def setup 26 | self.name = options["name"] || guess_name 27 | self.repository = options["repo"] || guess_repository 28 | self.recipe = options["recipe"] || guess_recipe 29 | self.server = options["server"] || 'your-server-address' 30 | template 'Capfile.erb', 'Capfile' 31 | end 32 | 33 | private 34 | 35 | def guess_name 36 | Dir.pwd.split(File::SEPARATOR).last 37 | end 38 | 39 | def guess_repository 40 | ShellCommand.execute('git config --get remote.origin.url').strip 41 | rescue 42 | warn "Unable to determine git repository. Setting to ." 43 | "" 44 | end 45 | 46 | def guess_recipe 47 | if File.exist?('Gemfile.lock') 48 | if File.read('Gemfile.lock') =~ / rails / 49 | 'rails' 50 | else 51 | 'ruby' 52 | end 53 | else 54 | 'static' 55 | end 56 | end 57 | end 58 | end -------------------------------------------------------------------------------- /spec/models/shell_command_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Recap::Support::ShellCommand do 4 | subject { Recap::Support::ShellCommand } 5 | 6 | it 'returns stdout output if execution succeeds' do 7 | subject.execute("echo 'foo'").should eql("foo\n") 8 | end 9 | 10 | it 'returns stdout output from last command if execution of multiple commands succeeds' do 11 | subject.execute("echo 'foo'", "echo 'bar'").should eql("bar\n") 12 | end 13 | 14 | it 'does not raise error if execution succeeds' do 15 | lambda { 16 | subject.execute("true") 17 | }.should_not raise_error 18 | end 19 | 20 | it 'does not raise error if execution of multiple commands succeeds' do 21 | lambda { 22 | subject.execute("true", "true") 23 | }.should_not raise_error 24 | end 25 | 26 | it 'raises error if execution fails' do 27 | lambda { 28 | subject.execute("false") 29 | }.should raise_error 30 | end 31 | 32 | it 'raises error if execution of any command fails' do 33 | lambda { 34 | subject.execute("true", "false", "true") 35 | }.should raise_error 36 | end 37 | 38 | it 'includes exist status in error message if execution fails' do 39 | lambda { 40 | subject.execute("false") 41 | }.should raise_error(RuntimeError, %r{Command:\sfalse$}) 42 | end 43 | 44 | it 'includes exist status in error message if execution fails' do 45 | lambda { 46 | subject.execute("false") 47 | }.should raise_error(RuntimeError, %r{Status:\s+1$}) 48 | end 49 | 50 | it 'includes stderr output in error message if execution fails' do 51 | lambda { 52 | subject.execute("echo 'error' 1>&2 && false") 53 | }.should raise_error(RuntimeError, %r{Message:\s+error$}) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /features/setting-environment-variables.feature: -------------------------------------------------------------------------------- 1 | Feature: Setting and unsetting environment config variables 2 | 3 | Scenario: Setting an environment variable 4 | 5 | Given a new project and a bootstrapped server 6 | When I run "cap env:set SECRET=very-secure" 7 | Then the variable "SECRET" should be set to "very-secure" 8 | 9 | Scenario: Setting an environment variable based on an existing variable 10 | 11 | Given a new project and a bootstrapped server 12 | When I run "cap env:set SUPER_PATH=\$PATH" 13 | Then the variable "SUPER_PATH" should be set to the application's PATH 14 | 15 | Scenario: Setting default environment variable values 16 | 17 | Given a new project and a bootstrapped server 18 | When I add a default environment variable "PASSWORD" with the value "sup3r-s3cr3t" to the project 19 | And I run "cap env:set" 20 | Then the variable "PASSWORD" should be set to "sup3r-s3cr3t" 21 | 22 | When I run "cap env:set PASSWORD=anoth3r-passw0rd" 23 | Then the variable "PASSWORD" should be set to "anoth3r-passw0rd" 24 | 25 | When I run "cap env:set PASSWORD=" 26 | Then the variable "PASSWORD" should be set back to "sup3r-s3cr3t" 27 | 28 | Scenario: Resetting back to default values 29 | 30 | Given a new project and a bootstrapped server 31 | And I add a default environment variable "PASSWORD" with the value "sup3r-s3cr3t" to the project 32 | 33 | When I run "cap env:set SECRET=something PASSWORD=anoth3r-passw0rd" 34 | Then the variable "SECRET" should be set to "something" 35 | And the variable "PASSWORD" should be set to "anoth3r-passw0rd" 36 | 37 | When I run "cap env:reset" 38 | Then the variable "PASSWORD" should be set back to "sup3r-s3cr3t" 39 | And the variable "SECRET" should have no value 40 | 41 | Scenario: Unsetting a variable 42 | 43 | Given a new project and a bootstrapped server 44 | And the variable "SECRET" is set to "very-secure" 45 | When I run "cap env:set SECRET=" 46 | Then the variable "SECRET" should have no value -------------------------------------------------------------------------------- /spec/tasks/ruby_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'recap/tasks/ruby' 3 | 4 | describe Recap::Tasks::Ruby do 5 | let :config do 6 | Capistrano::Configuration.new 7 | end 8 | 9 | let :namespace do 10 | config.ruby 11 | end 12 | 13 | let :deploy_to do 14 | 'path/to/deploy/to' 15 | end 16 | 17 | before do 18 | config.set :deploy_to, deploy_to 19 | Recap::Tasks::Ruby.load_into(config) 20 | end 21 | 22 | describe 'Tasks' do 23 | describe 'ruby:preflight' do 24 | before do 25 | namespace.stubs(:exit_code).with('grep rails path/to/deploy/to/Gemfile').returns("0") 26 | end 27 | 28 | it 'warns user if rails tasks not loaded for rails project' do 29 | namespace.logger.expects(:important) 30 | Recap::Tasks.stubs(:const_defined?).with(:Rails).returns(false) 31 | config.find_and_execute_task('ruby:preflight') 32 | end 33 | 34 | it 'skips warning if rails tasks have been loaded' do 35 | namespace.logger.expects(:important).never 36 | Recap::Tasks.stubs(:const_defined?).with(:Rails).returns(true) 37 | config.find_and_execute_task('ruby:preflight') 38 | end 39 | 40 | it 'skips warning if skip_rails_recipe_not_used_warning set' do 41 | namespace.logger.expects(:important).never 42 | Recap::Tasks.stubs(:const_defined?).with(:Rails).returns(false) 43 | config.set :skip_rails_recipe_not_used_warning, true 44 | config.find_and_execute_task('ruby:preflight') 45 | end 46 | 47 | it 'skips warning if rails project not detected' do 48 | namespace.logger.expects(:important).never 49 | namespace.stubs(:exit_code).with('grep rails path/to/deploy/to/Gemfile').returns("1") 50 | Recap::Tasks.stubs(:const_defined?).with(:Rails).returns(false) 51 | config.set :skip_rails_recipe_not_used_warning, true 52 | config.find_and_execute_task('ruby:preflight') 53 | end 54 | end 55 | end 56 | 57 | describe 'Callbacks' do 58 | before do 59 | Recap::Tasks::Preflight.load_into(config) 60 | end 61 | 62 | it 'runs `ruby:preflight` after `preflight:check`' do 63 | config.expects(:find_and_execute_task).with('ruby:preflight') 64 | config.trigger :after, config.find_task('preflight:check') 65 | end 66 | end 67 | end -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | Vagrant::Config.run do |config| 5 | # All Vagrant configuration is done here. The most common configuration 6 | # options are documented and commented below. For a complete reference, 7 | # please see the online documentation at vagrantup.com. 8 | 9 | # Every Vagrant virtual environment requires a box to build off of. 10 | config.vm.box = "precise64_vmware_fusion" 11 | 12 | # The url from where the 'config.vm.box' box will be fetched if it 13 | # doesn't already exist on the user's system. 14 | config.vm.box_url = "http://files.vagrantup.com/precise64_vmware_fusion.box" 15 | 16 | # Boot with a GUI so you can see the screen. (Default is headless) 17 | # config.vm.boot_mode = :gui 18 | 19 | # Assign this VM to a host-only network IP, allowing you to access it 20 | # via the IP. Host-only networks can talk to the host machine as well as 21 | # any other machines on the same network, but cannot be accessed (through this 22 | # network interface) by any external networks. 23 | # config.vm.network :hostonly, "33.33.33.10" 24 | 25 | # Assign this VM to a bridged network, allowing you to connect directly to a 26 | # network using the host's network device. This makes the VM appear as another 27 | # physical device on your network. 28 | # config.vm.network :bridged 29 | 30 | # Forward a port from the guest to the host, which allows for outside 31 | # computers to access the VM, whereas host only networking does not. 32 | # config.vm.forward_port 80, 8080 33 | 34 | # Share an additional folder to the guest VM. The first argument is 35 | # an identifier, the second is the path on the guest to mount the 36 | # folder, and the third is the path on the host to the actual folder. 37 | config.vm.share_folder 'test', '/recap/share', 'test-vm/share' 38 | 39 | # Enable provisioning with Puppet stand alone. Puppet manifests 40 | # are contained in a directory path relative to this Vagrantfile. 41 | # You will need to create the manifests directory and a manifest in 42 | # the file base.pp in the manifests_path directory. 43 | # 44 | # An example Puppet manifest to provision the message of the day: 45 | # 46 | # # group { "puppet": 47 | # # ensure => "present", 48 | # # } 49 | # # 50 | # # File { owner => 0, group => 0, mode => 0644 } 51 | # # 52 | # # file { '/etc/motd': 53 | # # content => "Welcome to your Vagrant-built virtual machine! 54 | # # Managed by Puppet.\n" 55 | # # } 56 | # 57 | config.vm.provision :puppet do |puppet| 58 | puppet.manifests_path = "test-vm/manifests" 59 | puppet.manifest_file = "base.pp" 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/recap/tasks/rails.rb: -------------------------------------------------------------------------------- 1 | # The rails tasks build on standard deployment with support for running 2 | # database migrations and precompiling assets. 3 | 4 | require 'recap/tasks/deploy' 5 | 6 | module Recap::Tasks::Rails 7 | extend Recap::Support::Namespace 8 | 9 | namespace :rails do 10 | # In traditional capistrano deployments, there's a configuration variable 11 | # `rails_env` to declare the rails environment. Recap prefers using 12 | # environment variables for things like this, and rails handily supports 13 | # the `RAILS_ENV` variable. As a default, `RAILS_ENV` is set to 14 | # `production`, but this can be changed using the `env:set` or 15 | # `env:edit` tasks. 16 | set_default_env :RAILS_ENV, 'production' 17 | 18 | # Rails asset precompilation can be slow, and isn't required on every 19 | # deploy. Unfortunately though, it's hard to tell which deploys need 20 | # assets to be compiled, and which don't. By default, recap will watch 21 | # the following files and directories and compile assets if they change 22 | # between deploys. 23 | _cset(:asset_precompilation_triggers, %w(app/assets vendor/assets Gemfile.lock config)) 24 | 25 | namespace :db do 26 | task :load_schema do 27 | if deployed_file_exists?("db/schema.rb") 28 | as_app_once './bin/rake db:create db:schema:load' 29 | elsif deployed_file_exists?("db/structure.sql") 30 | as_app_once './bin/rake db:create db:structure:load' 31 | end 32 | end 33 | 34 | task :migrate do 35 | if (deployed_file_exists?("db/schema.rb") || deployed_file_exists?("db/structure.sql")) && trigger_update?("db/") 36 | as_app_once './bin/rake db:migrate' 37 | end 38 | end 39 | end 40 | 41 | # The `rails:assets:precompile` runs rails' asset precompilation rake task on 42 | # the server. As assets come from so many sources (app/assets, vendor/assets 43 | # and from individual gems) it's not easy to determine whether compilation is 44 | # required, so it is done on every deploy. 45 | namespace :assets do 46 | namespace :precompile do 47 | task :if_changed do 48 | if asset_precompilation_triggers.detect {|path| trigger_update?(path)} 49 | top.rails.assets.precompile.default 50 | end 51 | end 52 | 53 | task :default do 54 | as_app "./bin/rake RAILS_GROUPS=assets assets:precompile" 55 | end 56 | end 57 | end 58 | 59 | # On every deploy, after the code is updated, run the database migrations 60 | # and precompile the assets. 61 | after "deploy:update_code", "rails:db:migrate", "rails:assets:precompile:if_changed" 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/models/capistrano_extensions_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Recap::Support::CapistranoExtensions do 4 | let :config do 5 | Capistrano::Configuration.new 6 | end 7 | 8 | describe "#edit_file" do 9 | before do 10 | Tempfile.any_instance.stubs(:path).returns('path/to/tempfile') 11 | config.stubs(:as_app) 12 | Recap::Support::ShellCommand.stubs(:execute_interactive) 13 | config.stubs(:get) 14 | config.stubs(:editor).returns("some-editor") 15 | end 16 | 17 | it 'downloads the file to a temporary file for editing' do 18 | config.expects(:get).with('remote/path/to/file', 'path/to/tempfile') 19 | File.stubs(:read).with('path/to/tempfile') 20 | config.edit_file('remote/path/to/file') 21 | end 22 | 23 | it 'opens the editor using `execute_interactive` so that Vi works' do 24 | config.stubs(:editor).returns('vi') 25 | File.stubs(:read).with('path/to/tempfile') 26 | Recap::Support::ShellCommand.expects(:execute_interactive).with('vi path/to/tempfile') 27 | config.edit_file('remote/path/to/file') 28 | end 29 | 30 | it 'returns the locally edited file contents' do 31 | File.expects(:read).with('path/to/tempfile').returns('edited contents') 32 | config.edit_file('remote/path/to/file').should eql('edited contents') 33 | end 34 | 35 | it 'fails if no EDITOR is set' do 36 | config.stubs(:editor).returns(nil) 37 | config.expects(:abort).with(regexp_matches(/To edit a remote file, either the EDITOR or DEPLOY_EDITOR environment variables must be set/)) 38 | config.edit_file('remote/path/to/file') 39 | end 40 | end 41 | 42 | describe '#trigger_update?' do 43 | context 'when forcing full deploy' do 44 | before(:each) do 45 | config.stubs(:force_full_deploy).returns(true) 46 | end 47 | 48 | it 'returns true' do 49 | config.trigger_update?('path/to/file').should be_true 50 | end 51 | end 52 | 53 | context 'when not forcing full deploy' do 54 | before(:each) do 55 | config.stubs(:force_full_deploy).returns(false) 56 | config.stubs(:changed_files).returns(['path/to/changed/file', 'directory/containing/changed/file']) 57 | end 58 | 59 | it 'returns false for a file path which has not changed' do 60 | config.trigger_update?('no/changes/here').should be_false 61 | end 62 | 63 | it 'returns true for a file path which has changed' do 64 | config.trigger_update?('path/to/changed/file').should be_true 65 | end 66 | 67 | it 'returns true for a directory path which contains a changed file' do 68 | config.trigger_update?('directory/containing/changed/').should be_true 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/recap/tasks/bundler.rb: -------------------------------------------------------------------------------- 1 | # The bundler recipe ensures that the application bundle is installed whenever the code is updated. 2 | 3 | require 'recap/tasks/deploy' 4 | 5 | module Recap::Tasks::Bundler 6 | extend Recap::Support::Namespace 7 | 8 | namespace :bundle do 9 | # Each bundle is declared in a `Gemfile`, by default in the root of the application directory. 10 | _cset(:bundle_gemfile) { "Gemfile" } 11 | 12 | # As well as a `Gemfile`, application repositories should also contain a `Gemfile.lock`. 13 | _cset(:bundle_gemfile_lock) { "#{bundle_gemfile}.lock" } 14 | 15 | # An application's gems are installed within the application directory. By default they are 16 | # placed under `vendor/gems`. 17 | _cset(:bundle_path) { "#{deploy_to}/vendor/gems" } 18 | 19 | # Not all gems are needed for production environments, so by default the `development`, `test` and 20 | # `assets` groups are skipped. 21 | _cset(:bundle_without) { "development test" } 22 | 23 | # The main bundle install command uses all the settings above, together with the `--deployment`, 24 | # `--binstubs` and `--quiet` flags 25 | _cset(:bundle_install_command) { "bundle install --gemfile #{bundle_gemfile} --path #{bundle_path} --deployment --quiet --binstubs --without #{bundle_without}" } 26 | 27 | namespace :install do 28 | # After cloning or updating the code, we only install the bundle if the `Gemfile` or `Gemfile.lock` have changed. 29 | desc "Install the latest gem bundle only if Gemfile or Gemfile.lock have changed" 30 | task :if_changed do 31 | if trigger_update?(bundle_gemfile) || trigger_update?(bundle_gemfile_lock) 32 | top.bundle.install.default 33 | end 34 | end 35 | 36 | # Occassionally it's useful to force an install (such as if something has gone wrong in 37 | # a previous deployment). 38 | desc "Install the latest gem bundle" 39 | task :default do 40 | if deployed_file_exists?(bundle_gemfile) 41 | if deployed_file_exists?(bundle_gemfile_lock) 42 | as_app bundle_install_command 43 | else 44 | abort 'Gemfile found without Gemfile.lock. The Gemfile.lock should be committed to the project repository' 45 | end 46 | else 47 | puts "Skipping bundle:install as no Gemfile found" 48 | end 49 | end 50 | end 51 | 52 | task :check_installed do 53 | if exit_code_as_app('bundle --version', '.') != "0" 54 | abort "The application user '#{application_user}' cannot execute `bundle`. Please check you have bundler installed." 55 | end 56 | end 57 | after 'preflight:check', 'bundle:check_installed' 58 | 59 | # To install the bundle automatically each time the code is updated or cloned, hooks are added to 60 | # the `deploy:clone_code` and `deploy:update_code` tasks. 61 | after 'deploy:clone_code', 'bundle:install:if_changed' 62 | after 'deploy:update_code', 'bundle:install:if_changed' 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/recap/tasks/preflight.rb: -------------------------------------------------------------------------------- 1 | # Before `recap` will work correctly, a small amount of setup work needs to be performed on 2 | # all target servers. 3 | # 4 | # First, each user who can deploy the app needs to have an account on each server, and must be able 5 | # to ssh into the box. They'll also each need to be sudoers. 6 | # 7 | # Secondly, each deploying user should set their git `user.name` and `user.email`. This can easily 8 | # be done by running: 9 | # 10 | # `git config --global user.email "you@example.com"` 11 | # `git config --global user.name "Your Name"` 12 | # 13 | # Finally, a user and group representing the application (and usually with the same name) should be 14 | # created. Where possible, the application user will run application code, while the group will own 15 | # application specific files. Each deploying user should be added to the application group. 16 | # 17 | # This preflight recipe checks each of these things in turn, and attempts to give helpful advice 18 | # should a check fail. 19 | 20 | require 'recap/tasks' 21 | 22 | module Recap::Tasks::Preflight 23 | extend Recap::Support::Namespace 24 | 25 | namespace :preflight do 26 | # The preflight check is pretty quick, so run it before every `deploy:setup` and `deploy`. 27 | before 'deploy:setup', 'preflight:check' 28 | before 'deploy', 'preflight:check' 29 | 30 | _cset(:remote_username) { capture('whoami').strip } 31 | 32 | task :check do 33 | # First check the `application_user` exists. 34 | if exit_code("id #{application_user}").strip != "0" 35 | abort %{ 36 | The application user '#{application_user}' doesn't exist. Did you run the `bootstrap` task? You can also create this user by logging into the server and running: 37 | 38 | sudo useradd #{application_user} 39 | \n} 40 | end 41 | 42 | # Then the `application_group`. 43 | if exit_code("id -g #{application_group}") != "0" 44 | abort %{ 45 | The application group '#{application_group}' doesn't exist. Did you run the `bootstrap` task? You can also create this group by logging into the server and running: 46 | 47 | sudo groupadd #{application_group} 48 | sudo usermod --append -G #{application_group} #{application_user} 49 | \n} 50 | end 51 | 52 | # Check the git configuration exists. 53 | if capture('git config user.name || true').strip.empty? || capture('git config user.email || true').strip.empty? 54 | abort %{ 55 | Your remote user must have a git user.name and user.email set. Did you run the `bootstrap` task? You can also set these by logging into the server as #{remote_username} and running: 56 | 57 | git config --global user.email "you@example.com" 58 | git config --global user.name "Your Name" 59 | \n} 60 | end 61 | 62 | # And finally check the remote user is a member of the `application_group`. 63 | unless capture('groups').split(" ").include?(application_group) 64 | abort %{ 65 | Your remote user must be a member of the '#{application_group}' group in order to perform deployments. Did you run the `bootstrap` task? You can also add yourself to this group by logging into the server and running: 66 | 67 | sudo usermod --append -G #{application_group} #{remote_username} 68 | \n} 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/recap/tasks/foreman.rb: -------------------------------------------------------------------------------- 1 | # These tasks configure recap to use Foreman to stop, start and restart your application processes. 2 | 3 | require 'recap/tasks/deploy' 4 | 5 | module Recap::Tasks::Foreman 6 | extend Recap::Support::Namespace 7 | 8 | namespace :foreman do 9 | # Processes are declared in a `Procfile`, by default in the root of the application directory. 10 | _cset(:procfile) { "Procfile" } 11 | 12 | # Foreman startup scripts are exported in `upstart` format by default. 13 | _cset(:foreman_export_format, "upstart") 14 | 15 | # Foreman startup scripts are generated based on the standard templates by default 16 | _cset(:foreman_template, nil) 17 | 18 | _cset(:foreman_template_option) { foreman_template ? "--template #{foreman_template}" : nil} 19 | 20 | # Scripts are exported (as the the application user) to a temporary location first. 21 | _cset(:foreman_tmp_location) { "#{deploy_to}/tmp/foreman" } 22 | 23 | # After exports, the scripts are moved to their final location, usually `/etc/init`. 24 | _cset(:foreman_export_location, "/etc/init") 25 | 26 | # The standard foreman export. 27 | _cset(:foreman_export_command) { "./bin/foreman export #{foreman_export_format} #{foreman_tmp_location} --procfile #{procfile} --app #{application} --user #{application_user} --log #{deploy_to}/log #{foreman_template_option}" } 28 | 29 | namespace :export do 30 | # After each deployment, the startup scripts are exported if either the `Procfile` or any custom Foreman templates have changed. 31 | task :if_changed do 32 | if trigger_update?(procfile) || (foreman_template && trigger_update?(foreman_template)) 33 | top.foreman.export.default 34 | end 35 | end 36 | 37 | # To export the scripts, they are first generated in a temporary location, then copied to their final 38 | # destination. This is done because the foreman export command needs to be run as the application user, 39 | # while sudo is required to write to `/etc/init`. 40 | desc 'Export foreman configuration' 41 | task :default do 42 | if deployed_file_exists?(procfile) 43 | sudo "mkdir -p #{deploy_to}/log" 44 | sudo "chown #{application_user}: #{deploy_to}/log" 45 | as_app foreman_export_command 46 | sudo "rm -f #{foreman_export_location}/#{application}*" 47 | sudo "cp #{foreman_tmp_location}/* #{foreman_export_location}" 48 | end 49 | end 50 | end 51 | 52 | # Starts all processes that form the application. 53 | desc 'Start all application processes' 54 | task :start do 55 | if deployed_file_exists?(procfile) 56 | sudo "start #{application}" 57 | end 58 | end 59 | 60 | # Restarts all processes that form the application. 61 | desc 'Restart all application processes' 62 | task :restart do 63 | if deployed_file_exists?(procfile) 64 | sudo "restart #{application} || sudo start #{application}" 65 | end 66 | end 67 | 68 | # Stops all processes that form the application. 69 | desc 'Stop all application processes' 70 | task :stop do 71 | if deployed_file_exists?(procfile) 72 | sudo "stop #{application}" 73 | end 74 | end 75 | 76 | after 'deploy:update_code', 'foreman:export:if_changed' 77 | after 'deploy:restart', 'foreman:restart' 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Recap 2 | 3 | [![Build Status](https://travis-ci.org/tomafro/recap.png?branch=1.x)](https://travis-ci.org/tomafro/recap) 4 | 5 | [Recap](https://github.com/freerange/recap) is an opinionated set of [Capistrano](https://github.com/capistrano/capistrano) deployment recipes, that use git's strengths to deploy applications and websites in a fast and simple manner. 6 | 7 | 8 | ## Features & Aims 9 | 10 | * Releases are managed using git. All code is deployed to a single directory, and git tags are used to manage different released versions. No `releases`, `current` or `shared` directories are created, avoiding unnecessary sym-linking. 11 | * Deployments do the minimum work possible, using git to determine whether tasks need to run. e.g. the `bundle:install` task only runs if the app contains a `Gemfile.lock` file and it has changed since the last deployment. 12 | * Applications have their own user account and group, owning all of that application's associated files and processes. This gives them a dedicated environment, allowing environment variables to be used for application specific configuration. Tasks such as `env`, `env:set` and `env:edit` make setting and changing these variables easy. 13 | * Personal accounts are used to deploy to the server, distinct from the application user. The right to deploy an application is granted simply by adding a user to the application group. 14 | 15 | 16 | ## Documentation 17 | 18 | For more information, the main documentation can be found at [http://gofreerange.com/recap/docs](http://gofreerange.com/recap/docs). 19 | 20 | 21 | ## Prerequistes 22 | 23 | * Recap's built-in tasks only support deploying to Ubuntu 24 | * Your user account (as opposed to the application account) must be able to `sudo` 25 | * Your user account should be able to connect to the remote git repository from your deployment server(s) 26 | 27 | 28 | ## Source 29 | 30 | The source code is available [on Github](https://github.com/freerange/recap). 31 | 32 | 33 | ## Running Tests 34 | 35 | - Run the following commands from the checked out project directory. 36 | - Install dependencies (assumes the bundler gem is installed). 37 | 38 | `$ bundle install` 39 | 40 | - Run specs 41 | 42 | `$ bundle exec rake` 43 | 44 | - Install [VirtualBox](https://www.virtualbox.org/) - only necessary if you want to run [Cucumber](https://github.com/cucumber/cucumber) features. 45 | - Install and provision a test VM based on the [Vagrantfile](https://github.com/freerange/recap/blob/master/Vagrantfile) (assumes VirtualBox is installed) 46 | 47 | `$ bundle exec vagrant up` 48 | 49 | - Run features 50 | 51 | `$ bundle exec cucumber` 52 | 53 | 54 | ## Publishing documentation 55 | 56 | This defaults to publishing to gofreerange.com but that can be customised by setting the `RECAP_DOCS_HOST` environment variable. 57 | 58 | The `fl-rocco` gem relies on the `pygments` Python package being installed (`pip install pygments`); more specifically the `pygmentize` binary being available in the `PATH`. 59 | 60 | $ bundle exec rake doc publish 61 | 62 | *NOTE*. The recap docs rely on a rocco.css file being available at `#{RECAP_DOCS_HOST}/stylesheets/rocco.css`. This was [added to our site in e41bac][e41bac] 63 | 64 | [e41bac]: https://github.com/freerange/site/commit/e41bac9954eddd2ca9dda0f8d034bb3f8ac77bd3 65 | 66 | 67 | ## Credits 68 | 69 | Recap was written by [Tom Ward](http://tomafro.net) and the other members of [Go Free Range](http://gofreerange.com). 70 | 71 | 72 | ## License 73 | 74 | Recap is released under the [MIT License](https://github.com/freerange/recap/blob/master/LICENSE). 75 | -------------------------------------------------------------------------------- /features/steps/capistrano_steps.rb: -------------------------------------------------------------------------------- 1 | Before do 2 | FileUtils.rm_rf 'test-vm/share/projects' 3 | FileUtils.rm_rf 'test-vm/share/gems' 4 | `bundle exec vagrant sandbox on` unless ENV['SKIP_ROLLBACK'] 5 | end 6 | 7 | After do 8 | `bundle exec vagrant sandbox rollback` unless ENV['SKIP_ROLLBACK'] 9 | if project 10 | project.run_on_server "sudo stop #{project.name} || true" 11 | project.run_on_server "sudo rm -rf /etc/init/#{project.name}* || true" 12 | end 13 | end 14 | 15 | Given /^a new (ruby )?project and a bootstrapped server$/ do |project_type| 16 | type = (project_type || 'static').strip 17 | start_project server: server, capfile: { recap_require: "recap/recipes/#{type}" } 18 | project.run_cap 'bootstrap' 19 | end 20 | 21 | Given /^a deployed project$/ do 22 | start_project server: server 23 | project.run_cap 'bootstrap' 24 | project.run_cap 'deploy:setup deploy' 25 | end 26 | 27 | Given /^a bundle requiring version "([^"]*)" of "([^"]*)"$/ do |version, gem| 28 | project.add_gem_to_bundle(gem, version) 29 | end 30 | 31 | Given /^the variable "([^"]*)" is set to "([^"]*)"$/ do |name, value| 32 | project.run_cap "env:set #{name}=#{value}" 33 | end 34 | 35 | Given /^the project has an application process defined in a Procfile$/ do 36 | @application_process = 'an-application-process' 37 | project.add_foreman_to_bundle 38 | project.add_gem_to_bundle @application_process, '1.0.0' 39 | project.add_command_to_procfile 'process', "bin/#{@application_process} --server" 40 | end 41 | 42 | When /^I update the bundle to require version "([^"]*)" of "([^"]*)"$/ do |version, gem| 43 | project.add_gem_to_bundle(gem, version) 44 | end 45 | 46 | When /^I run "cap ([^"]*)"$/ do |command| 47 | project.run_cap command 48 | end 49 | 50 | When /^I commit changes to the project$/ do 51 | project.commit_changes 52 | end 53 | 54 | When /^I commit and deploy changes to the project$/ do 55 | project.commit_changes 56 | project.run_cap 'deploy' 57 | end 58 | 59 | When /^I wait for the server to start$/ do 60 | sleep(5) 61 | end 62 | 63 | When /^I add a default environment variable "([^"]*)" with the value "([^"]*)" to the project$/ do |name, value| 64 | project.add_default_env_value_to_capfile(name, value) 65 | end 66 | 67 | Then /^the project should be deployed$/ do 68 | project.deployed_version.should eql(project.latest_version) 69 | end 70 | 71 | Then /^the latest version of the project should be deployed$/ do 72 | project.deployed_version.should eql(project.latest_version) 73 | end 74 | 75 | Then /^the previous project version should be deployed$/ do 76 | project.deployed_version.should eql(project.previous_version) 77 | end 78 | 79 | Then /^the deployed project should include version "([^"]*)" of "([^"]*)"$/ do |version, gem| 80 | project.run_on_server("ruby bin/#{gem} --version").strip.should eql(version) 81 | end 82 | 83 | Then /^the variable "([^"]*)" should be set (?:back )?to "([^"]*)"$/ do |name, value| 84 | project.run_on_server("sudo su - #{project.name} -c 'env | grep #{name}'", ".").strip.should eql("#{name}=#{value}") 85 | end 86 | 87 | Then /^the variable "([^"]*)" should be set to the application's PATH$/ do |name| 88 | path = project.run_on_server("sudo su - #{project.name} -c 'echo $PATH'", ".").strip 89 | project.run_on_server("sudo su - #{project.name} -c 'env | grep #{name}'", ".").strip.should eql("#{name}=#{path}") 90 | end 91 | 92 | Then /^the variable "([^"]*)" should have no value$/ do |name| 93 | project.run_on_server("sudo su - #{project.name} -c 'env'", ".").include?("#{name}=").should be_false 94 | end 95 | 96 | Then /^the project should own the running application process$/ do 97 | project.run_on_server("ps -U #{project.name} u").include?(@application_process).should be_true 98 | end 99 | 100 | Then /^the running application process should know that "([^"]*)" is set to "([^"]*)"$/ do |name, value| 101 | project.run_on_server("/usr/bin/curl localhost:3500/env | grep #{name}").strip.should eql("#{name}=#{value}") 102 | end 103 | -------------------------------------------------------------------------------- /lib/recap/tasks/bootstrap.rb: -------------------------------------------------------------------------------- 1 | # Recap has a number of requirements on your server before you can deploy applications 2 | # with it. These include: 3 | # 4 | # - Each application needs its own account on the server. The full account environment 5 | # is loaded whenever an application command or process is run, so this is the place where 6 | # other application specific configuration should happen. 7 | # - Each deploying user needs a personal account on the server which they should be able to 8 | # ssh into. 9 | # - This personal account needs to be able to `sudo`, both to switch to the application user 10 | # and to run other administrative commands. 11 | 12 | require 'recap/tasks' 13 | 14 | module Recap::Tasks::Bootstrap 15 | extend Recap::Support::Namespace 16 | 17 | # The bootstrap namespace has a couple of task that help configure application and personal accounts 18 | # to meet these requirements. 19 | namespace :bootstrap do 20 | _cset(:remote_username) { capture('whoami').strip } 21 | _cset(:application_home) { "/home/#{application_user}"} 22 | 23 | # The `bootstrap:application` task sets up the account on the server the application itself uses. This 24 | # account should be dedicated to running this application. 25 | desc 'Sets up the server account used by the application, including home directory and environment support' 26 | task :application do 27 | # If the account doesn't already exist on the server, the task creates it. 28 | if exit_code("id #{application_user}").strip != "0" 29 | sudo "useradd #{application_user} -d #{application_home}" 30 | end 31 | 32 | # If the home directory doesn't exist, or isn't both readable and writable by members of the application 33 | # group (all the accounts allowed to deploy the app) then the task creates the directory and fixes 34 | # file permissions. 35 | sudo "mkdir -p #{application_home}" 36 | sudo "chown #{application_user}:#{application_group} #{application_home}" 37 | sudo "chmod 755 #{application_home}" 38 | 39 | # A script `.recap` is added to set the configuration environment (set with `env:set` and 40 | # `env:edit` tasks). The script loads the `.env` file in the users home folder, creates 41 | # a new copy with `export ` prefixed to each line, and sources this new copy. 42 | put_as_app %{ 43 | if [ -s #{application_home}/.env ]; then 44 | sed -e 's/\\r//g' -e 's/^/export /g' #{application_home}/.env > #{application_home}/.recap-env-export 45 | . #{application_home}/.recap-env-export 46 | fi 47 | }, "#{application_home}/.recap" 48 | 49 | # Finally, `.profile` needs to source the `.recap` script, so that the configuration environment is 50 | # available whenever the environment is loaded. 51 | as_app "touch .profile", "#{application_home}" 52 | 53 | if exit_code("grep '\\. #{application_home}/\\.recap' #{application_home}/.profile") != "0" 54 | as_app %{echo ". #{application_home}/.recap" >> .profile}, "#{application_home}" 55 | end 56 | end 57 | 58 | # The `bootstrap:user` task sets up the personal accounts of users who can deploy applications. 59 | # In order to deploy a particular app, the account's git configuration must be set (so 60 | # that releases can be tagged), and the account must be a member of the application group. 61 | desc 'Sets up the server account used by a deploying user' 62 | task :user do 63 | git_user_name = Recap::Support::ShellCommand.execute("git config user.name").strip 64 | git_user_email = Recap::Support::ShellCommand.execute("git config user.email").strip 65 | run "git config --global user.name '#{git_user_name}'" 66 | run "git config --global user.email '#{git_user_email}'" 67 | sudo "usermod --append -G #{application_group} #{remote_username}" 68 | 69 | if repository.match /github\.com/ 70 | run "mkdir -p ~/.ssh; touch ~/.ssh/known_hosts; (ssh-keygen -f ~/.ssh/known_hosts -H -F github.com | grep 'github.com') || ssh-keyscan -H github.com > ~/.ssh/known_hosts" 71 | end 72 | end 73 | 74 | # The `bootstrap` task simply runs both the `bootstrap:application` and `bootstrap:user` tasks 75 | # in turn. 76 | task :default do 77 | application 78 | user 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/recap/tasks/env.rb: -------------------------------------------------------------------------------- 1 | # Recap encourages the storage of application configuration (such as database passwords, S3 keys and 2 | # other things that change between deploys) in environment variables. 3 | # [12factor.net](http://www.12factor.net) has [a good set of reasons](http://www.12factor.net/config) 4 | # why this is desirable). 5 | # 6 | # To enable this, [recap](https://github.com/freerange/recap) stores these configuration variables 7 | # in `.env`, and adds a script to the user's `.profile` to set these whenever the environment is 8 | # loaded (see [bootstrap](bootstrap.html)). 9 | # 10 | # Variables can be set in two ways. First, using either the `env:set` or `env:edit` tasks, 11 | # the `.env` file can be directly manipulated. This is generally the best way to manipulate 12 | # these values. 13 | # 14 | # The other way to set them is using the `set_default_env` method directly in your `Capfile`. 15 | # This sets a default value, which will be used if no other value is set. An example where 16 | # this might be useful is where you know your app should run using ruby 1.8.7. Using 17 | # `set_default_env :RBENV_VERSION, "1.8.7-p352"` in your `Capfile` will use this ruby as the default. 18 | # Then, in a different deployment you might want to test using a different version of ruby, 19 | # so could use `cap env:set RBENV_VERSION=1.9.3-p0` to override the default. 20 | 21 | require 'recap/tasks' 22 | 23 | module Recap::Tasks::Env 24 | extend Recap::Support::Namespace 25 | 26 | namespace :env do 27 | _cset(:environment_file) { "/home/#{application_user}/.env" } 28 | 29 | # The `env` task displays the current configuration environment. Note that this doesn't 30 | # include all environment variables, only those stored in the `.env` file. 31 | desc 'View the current server environment' 32 | task :default do 33 | if current_environment.empty? 34 | puts "There are no config variables set" 35 | else 36 | puts "The config variables are:" 37 | puts 38 | puts current_environment 39 | end 40 | end 41 | 42 | # A single variable can be set using the `env:set` task, followed by a variable and value, 43 | # for example `cap env:set VARIABLE=VALUE`. Variables can be unset using `cap env:set VARIABLE=`. 44 | desc 'Set a variable in the environment, using "cap env:set VARIABLE=VALUE". Unset using "cap env:set VARIABLE="' 45 | task :set do 46 | env = env_argv.inject(current_environment) do |env, string| 47 | env.set_string(string) 48 | logger.debug "Setting #{string}" 49 | logger.debug "Env is now: #{env}" 50 | env 51 | end 52 | update_remote_environment(env) 53 | default 54 | end 55 | 56 | # The `env:edit` task uses your EDITOR to load the `.env` file locally, saving any changes 57 | # to all servers. 58 | desc 'Edit the server environment' 59 | task :edit do 60 | content = edit_file environment_file 61 | env = Recap::Support::Environment.from_string(content) 62 | update_remote_environment(env) 63 | default 64 | end 65 | 66 | # The `env:reset` tasks reverts all variables back to their default values. If there is no default value, 67 | # the variable will be removed. 68 | desc 'Reset the server environment to its default values' 69 | task :reset do 70 | as_app "rm -f #{environment_file}", "~" 71 | set 72 | end 73 | 74 | def current_environment 75 | @current_environment ||= begin 76 | if deployed_file_exists?(environment_file, '.') 77 | Recap::Support::Environment.from_string(capture("cat #{environment_file}")) 78 | else 79 | Recap::Support::Environment.new 80 | end 81 | end 82 | end 83 | 84 | def update_remote_environment(env) 85 | logger.debug "Env is now #{env}" 86 | 87 | default_env.each do |name, value| 88 | env.set(name, value) unless env.get(name) 89 | end 90 | 91 | if env.empty? 92 | as_app "rm -f #{environment_file}", "~" 93 | else 94 | put_as_app env.to_s, environment_file 95 | end 96 | end 97 | end 98 | 99 | # Default environment values can be set by a recipe using `set_default_env :NAME, 'VALUE'`. 100 | def set_default_env(name, value) 101 | default_env[name.to_s] = value 102 | end 103 | 104 | def default_env 105 | @default_env ||= {} 106 | end 107 | 108 | def env_argv 109 | ARGV[1..-1] 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/recap/support/capistrano_extensions.rb: -------------------------------------------------------------------------------- 1 | require 'tempfile' 2 | 3 | # These methods are used by recap tasks to run commands and detect when files have changed 4 | # as part of a deployment. 5 | 6 | module Recap::Support::CapistranoExtensions 7 | # Run a command as the application user 8 | def as_app(command, pwd = deploy_to) 9 | sudo "su - #{application_user} -c 'cd #{pwd} && #{command}'" 10 | end 11 | 12 | def as_app_once(command, pwd = deploy_to) 13 | sudo "su - #{application_user} -c 'cd #{pwd} && #{command}'", :once => true 14 | end 15 | 16 | # Put a string into a file as the application user 17 | def put_as_app(string, path) 18 | put string, "/tmp/recap-put-as-app" 19 | as_app "cp /tmp/recap-put-as-app #{path} && chmod g+rw #{path}", "/" 20 | ensure 21 | run "rm /tmp/recap-put-as-app" 22 | end 23 | 24 | def editor 25 | ENV['DEPLOY_EDITOR'] || ENV['EDITOR'] 26 | end 27 | 28 | # Edit a file on the remote server, using a local editor 29 | def edit_file(path) 30 | if editor 31 | as_app "touch #{path} && chmod g+rw #{path}" 32 | local_path = Tempfile.new('deploy-edit').path 33 | get(path, local_path) 34 | Recap::Support::ShellCommand.execute_interactive("#{editor} #{local_path}") 35 | File.read(local_path) 36 | else 37 | abort "To edit a remote file, either the EDITOR or DEPLOY_EDITOR environment variables must be set" 38 | end 39 | end 40 | 41 | # Run a git command in the `deploy_to` directory 42 | def git(command) 43 | run "cd #{deploy_to} && umask 002 && sg #{application_group} -c \"git #{command}\"" 44 | end 45 | 46 | # Capture the result of a git command run within the `deploy_to` directory 47 | def capture_git(command) 48 | capture "cd #{deploy_to} && umask 002 && sg #{application_group} -c 'git #{command}'" 49 | end 50 | 51 | def exit_code(command) 52 | capture("#{command} > /dev/null 2>&1; echo $?").strip 53 | end 54 | 55 | def exit_code_as_app(command, pwd = deploy_to) 56 | capture(%|sudo -p 'sudo password: ' su - #{application_user} -c 'cd #{pwd} && #{command} > /dev/null 2>&1'; echo $?|).strip 57 | end 58 | 59 | # Find the latest tag from the repository. As `git tag` returns tags in order, and our release 60 | # tags are timestamps, the latest tag will always be the last in the list. 61 | def latest_tag_from_repository 62 | tags = capture_git("tag").strip.split 63 | tags.grep(release_matcher).last 64 | end 65 | 66 | # Does the given file exist within the deployment directory? 67 | def deployed_file_exists?(path, root_path = deploy_to) 68 | exit_code("cd #{root_path} && [ -f #{path} ]") == "0" 69 | end 70 | 71 | # Does the given directory exist within the deployment directory? 72 | def deployed_dir_exists?(path, root_path = deploy_to) 73 | exit_code("cd #{root_path} && [ -d #{path} ]") == "0" 74 | end 75 | 76 | # Has the given path been created or changed since the previous deployment? During the first 77 | # successful deployment this will always return true if the file exists. 78 | def deployed_file_changed?(path) 79 | return deployed_file_exists?(path) unless latest_tag 80 | exit_code("cd #{deploy_to} && git diff --exit-code #{latest_tag} origin/#{branch} #{path}") == "1" 81 | end 82 | 83 | def changed_files 84 | @changed_files ||= if latest_tag 85 | capture_git("diff --name-only #{latest_tag} origin/#{branch} | cat").split 86 | else 87 | capture_git("ls-files | cat").split 88 | end 89 | end 90 | 91 | def trigger_update?(path) 92 | force_full_deploy || changed_files.detect {|p| p[0, path.length] == path} 93 | end 94 | 95 | def claim_lock(message) 96 | begin 97 | sudo "[ ! -e #{deploy_lock_file} ] && echo '#{message}' > #{deploy_lock_file}" 98 | rescue Exception => e 99 | abort %{ 100 | Failed to claim lock: #{capture("cat #{deploy_lock_file}")} 101 | 102 | If you think this lock no longer applies, clear it using the `deploy:unlock` task 103 | and try again. 104 | } 105 | end 106 | end 107 | 108 | def release_lock 109 | sudo "rm -rf #{deploy_lock_file}" 110 | end 111 | 112 | def transaction_with_lock(message) 113 | on_rollback { release_lock } 114 | transaction do 115 | claim_lock(message) 116 | yield 117 | release_lock 118 | end 119 | end 120 | 121 | def _cset(name, *args, &block) 122 | unless exists?(name) 123 | set(name, *args, &block) 124 | end 125 | end 126 | 127 | Capistrano::Configuration.send :include, self 128 | end 129 | -------------------------------------------------------------------------------- /spec/tasks/env_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Recap::Tasks::Env do 4 | let :config do 5 | Capistrano::Configuration.new 6 | end 7 | 8 | let :namespace do 9 | config.env 10 | end 11 | 12 | before do 13 | Recap::Tasks::Env.load_into(config) 14 | end 15 | 16 | describe 'Settings' do 17 | describe '#environment_file' do 18 | it 'defaults to /home/ + application_user + /.env' do 19 | config.set :application_user, 'marigold' 20 | config.environment_file.should eql('/home/marigold/.env') 21 | end 22 | end 23 | end 24 | 25 | describe 'Tasks' do 26 | before do 27 | config.set :environment_file, 'path/to/.env' 28 | namespace.stubs(:deployed_file_exists?).with(config.environment_file, '.').returns(true) 29 | namespace.stubs(:capture).with("cat #{config.environment_file}").returns('') 30 | namespace.stubs(:puts) 31 | end 32 | 33 | describe 'env' do 34 | it 'outputs the current environment if one exists' do 35 | namespace.stubs(:capture).with("cat #{config.environment_file}").returns("A=b\nX=Y") 36 | namespace.expects(:puts).with('The config variables are:') 37 | namespace.expects(:puts).with(responds_with(:to_s, Recap::Support::Environment.from_string("A=b\nX=Y").to_s)) 38 | config.find_and_execute_task('env') 39 | end 40 | end 41 | 42 | describe 'env:set' do 43 | it 'merges the edited environment with the default one' do 44 | config.set_default_env 'A', 'b' 45 | namespace.expects(:put_as_app).with(Recap::Support::Environment.from_string("A=b\nX=Y").to_s, config.environment_file) 46 | namespace.stubs(:env_argv).returns(['X=Y']) 47 | config.find_and_execute_task('env:set') 48 | end 49 | 50 | it 'allows overriding of the default environment' do 51 | config.set_default_env 'A', 'b' 52 | namespace.expects(:put_as_app).with(Recap::Support::Environment.from_string('A=c').to_s, config.environment_file) 53 | namespace.stubs(:env_argv).returns(['A=c']) 54 | config.find_and_execute_task('env:set') 55 | end 56 | 57 | it 'can unset a variable by assigning an empty value to it' do 58 | namespace.stubs(:capture).with("cat #{config.environment_file}").returns("X=Y\nA=b") 59 | namespace.expects(:put_as_app).with(Recap::Support::Environment.from_string('X=Y').to_s, config.environment_file) 60 | namespace.stubs(:env_argv).returns(['A=']) 61 | config.find_and_execute_task('env:set') 62 | end 63 | 64 | it 'uploads the new environment' do 65 | namespace.expects(:put_as_app).with(Recap::Support::Environment.from_string('X=Y').to_s, config.environment_file) 66 | namespace.stubs(:env_argv).returns(['X=Y']) 67 | config.find_and_execute_task('env:set') 68 | end 69 | 70 | it 'removes the environment if it is empty' do 71 | namespace.stubs(:capture).with("cat #{config.environment_file}").returns("X=Y") 72 | namespace.expects(:as_app).with("rm -f #{config.environment_file}", '~') 73 | namespace.stubs(:env_argv).returns(['X=']) 74 | config.find_and_execute_task('env:set') 75 | end 76 | end 77 | 78 | describe 'env:reset' do 79 | it 'removes the environment file from the server' do 80 | namespace.stubs(:env_argv).returns([]) 81 | namespace.expects(:as_app).with("rm -f #{config.environment_file}", '~').at_least_once 82 | config.find_and_execute_task('env:reset') 83 | end 84 | end 85 | 86 | describe 'env:edit' do 87 | it 'merges the edited environment with the default one' do 88 | config.set_default_env 'A', 'b' 89 | namespace.stubs(:edit_file).returns('X=Y') 90 | namespace.expects(:put_as_app).with(Recap::Support::Environment.from_string("A=b\nX=Y").to_s, config.environment_file) 91 | config.find_and_execute_task('env:edit') 92 | end 93 | 94 | it 'allows overriding of the default environment' do 95 | config.set_default_env 'A', 'b' 96 | namespace.stubs(:edit_file).returns('A=c') 97 | namespace.expects(:put_as_app).with(Recap::Support::Environment.from_string('A=c').to_s, config.environment_file) 98 | config.find_and_execute_task('env:edit') 99 | end 100 | 101 | it 'uploads the new environment' do 102 | namespace.stubs(:edit_file).returns('X=Y') 103 | namespace.expects(:put_as_app).with(Recap::Support::Environment.from_string('X=Y').to_s, config.environment_file) 104 | config.find_and_execute_task('env:edit') 105 | end 106 | 107 | it 'removes the environment if it is empty' do 108 | namespace.stubs(:edit_file).returns('') 109 | namespace.expects(:as_app).with("rm -f #{config.environment_file}", '~') 110 | config.find_and_execute_task('env:edit') 111 | end 112 | end 113 | end 114 | end -------------------------------------------------------------------------------- /spec/models/environment_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Recap::Support::Environment do 4 | describe '#empty?' do 5 | it 'returns true if no variables set' do 6 | Recap::Support::Environment.new.empty?.should be_true 7 | end 8 | 9 | it 'returns false if no variables set' do 10 | Recap::Support::Environment.new('FIRST' => 'One').empty?.should be_false 11 | end 12 | end 13 | 14 | describe '#include?(key)' do 15 | it 'returns true if variables set' do 16 | Recap::Support::Environment.new('FIRST' => 'One').include?('FIRST').should be_true 17 | end 18 | 19 | it 'returns false if variable has not been set' do 20 | Recap::Support::Environment.new('DIFFERENT' => 'One').include?('FIRST').should be_false 21 | end 22 | end 23 | 24 | describe '#get(name)' do 25 | subject do 26 | Recap::Support::Environment.new('FIRST' => 'One') 27 | end 28 | 29 | it 'returns value if variable set' do 30 | subject.get('FIRST').should eql('One') 31 | end 32 | 33 | it 'returns nil if variable not set' do 34 | subject.get('MISSING').should be_nil 35 | end 36 | end 37 | 38 | describe '#set(name, value)' do 39 | subject do 40 | Recap::Support::Environment.new('FIRST' => 'One') 41 | end 42 | 43 | it 'sets variable value' do 44 | subject.set('SECOND', 'Two') 45 | subject.get('SECOND').should eql('Two') 46 | end 47 | 48 | it 'unsets variable if value is nil' do 49 | subject.set('FIRST', nil) 50 | subject.get('FIRST').should be_nil 51 | subject.include?('FIRST').should be_false 52 | end 53 | 54 | it 'unsets variable if value is empty' do 55 | subject.set('FIRST', '') 56 | subject.get('FIRST').should be_nil 57 | subject.include?('FIRST').should be_false 58 | end 59 | end 60 | 61 | describe '#each' do 62 | subject do 63 | Recap::Support::Environment.new('FIRST' => 'One', 'SECOND' => 'Two', 'THIRD' => 'Three', 'FOURTH' => 'Four') 64 | end 65 | 66 | it 'yields each variable and value in turn (ordered alphabetically)' do 67 | result = [] 68 | subject.each do |k, v| 69 | result << [k, v] 70 | end 71 | result.should eql([['FIRST', 'One'], ['FOURTH', 'Four'], ['SECOND', 'Two'], ['THIRD', 'Three']]) 72 | end 73 | end 74 | 75 | describe '#merge(variables)' do 76 | subject do 77 | Recap::Support::Environment.new('FIRST' => 'One') 78 | end 79 | 80 | it 'sets each variable value' do 81 | subject.merge('SECOND' => 'Two', 'THIRD' => 'Three') 82 | subject.get('SECOND').should eql('Two') 83 | subject.get('THIRD').should eql('Three') 84 | end 85 | 86 | it 'preserves existing values if not provided' do 87 | subject.merge('ANYTHING' => 'Goes') 88 | subject.get('FIRST').should eql('One') 89 | end 90 | 91 | it 'overides existing values if provided' do 92 | subject.merge('FIRST' => 'Un') 93 | subject.get('FIRST').should eql('Un') 94 | end 95 | end 96 | 97 | describe '#to_s' do 98 | subject do 99 | Recap::Support::Environment.new('FIRST' => 'One', 'SECOND' => 'Two', 'THIRD' => nil, 'FOURTH' => 'Four').to_s 100 | end 101 | 102 | it 'declares each variable on its own line' do 103 | subject.match(/^FIRST=One\n/).should_not be_nil 104 | subject.match(/^SECOND=Two\n/).should_not be_nil 105 | subject.match(/^FOURTH=Four\n/).should_not be_nil 106 | end 107 | 108 | it 'ignores nil variable values' do 109 | subject.match(/THIRD/).should be_nil 110 | end 111 | 112 | it 'orders variables alphabetically' do 113 | indexes = ['FIRST', 'FOURTH', 'SECOND'].map {|k| subject.index(k)} 114 | indexes.sort.should eql(indexes) 115 | end 116 | end 117 | 118 | describe '.from_string(declarations)' do 119 | it 'builds instance using string representation' do 120 | instance = Recap::Support::Environment.from_string("FIRST=One\nSECOND=Two\n") 121 | instance.get('FIRST').should eql('One') 122 | instance.get('SECOND').should eql('Two') 123 | end 124 | 125 | it 'handles variables with numbers and underscores in their names' do 126 | instance = Recap::Support::Environment.from_string("THIS_1=One\nThose_2=Two\n") 127 | instance.get('THIS_1').should eql('One') 128 | instance.get('Those_2').should eql('Two') 129 | end 130 | 131 | it 'gracefully ignores missing newline at end of string' do 132 | instance = Recap::Support::Environment.from_string("FIRST=One\nSECOND=Two") 133 | instance.get('FIRST').should eql('One') 134 | instance.get('SECOND').should eql('Two') 135 | end 136 | 137 | it 'acts as the inverse of #to_s' do 138 | string = "FIRST=One\nSECOND=Two\nTHIRD=three\n" 139 | excercised = Recap::Support::Environment.from_string(Recap::Support::Environment.from_string(string).to_s).to_s 140 | excercised.should eql(string) 141 | end 142 | end 143 | end -------------------------------------------------------------------------------- /lib/recap.rb: -------------------------------------------------------------------------------- 1 | # This is the documentation for [recap](https://github.com/freerange/recap), a simple, opinionated 2 | # set of capistrano deployment recipes. 3 | # 4 | # Inspired in part by 5 | # [this blog post](https://github.com/blog/470-deployment-script-spring-cleaning), these recipes use 6 | # git's strengths to deploy applications in a faster, simpler manner than the standard capistrano 7 | # deployment. 8 | # 9 | # ### Aims and features 10 | # 11 | # Releases are managed using git. All code is deployed to a single directory, and git tags are 12 | # used to manage different released versions. No `releases`, `current` or `shared` directories are 13 | # created, avoiding unnecessary sym-linking. For more information on how releases work, see 14 | # [recap/tasks/deploy.rb](recap/tasks/deploy.html). 15 | # 16 | # Deployments do the minimum work possible, using git to determine whether tasks need to run. e.g. 17 | # the `bundle:install` task only runs if the app contains a `Gemfile.lock` file and it has changed 18 | # since the last deployment. You can see how this works in 19 | # [recap/tasks/bundler.rb](recap/tasks/bundler.html). 20 | # 21 | # Applications have their own user and group, owning all of that application's associated 22 | # files and processes. This gives them a dedicated environment, allowing environment variables to 23 | # be used for application specific configuration. Tasks such as `env`, `env:set` and `env:edit` make 24 | # setting and changing these variables easy. [recap/tasks/env.rb](recap/tasks/env.html) has more 25 | # information about using these environment variables. 26 | # 27 | # Personal accounts are used to deploy to the server, distinct from the application user. The right 28 | # to deploy an application is granted simply by adding a user to the application group. Most tasks 29 | # are run as the application user using `sudo su...`. To avoid having to enter a password when 30 | # running them, these lines can be added to `/etc/sudoers.d/application` 31 | # (change `application` to the name of your app). 32 | # 33 | #
%application ALL=NOPASSWD: /sbin/start application*
 34 | # %application ALL=NOPASSWD: /sbin/stop application*
 35 | # %application ALL=NOPASSWD: /sbin/restart application*
 36 | # %application ALL=NOPASSWD: /bin/su - application*
 37 | # %application ALL=NOPASSWD: /bin/su application*
38 | # 39 | # ### Limitations and Constraints 40 | # 41 | # Recap has been developed and tested using Ubuntu 11.04. It may work well with 42 | # other flavours of unix, but proceed with caution. 43 | # 44 | # Recap also uses a different file layout than other capistrano-based deployments, so other 45 | # recipes may not work well with it. You can improve compatibility with other recipes using 46 | # [recap/support/compatibility.rb](recap/support/compatibility.html). 47 | # 48 | # ### Getting started 49 | # 50 | # To use recap you'll need a project stored in `git`. You'll also need a server with `git` installed 51 | # and if deploying a rails or ruby app, `bundler` and `ruby` too. Finally you need an account on the 52 | # server which you can SSH into and which is a sudoer. 53 | # 54 | # #### Preparing your project 55 | # 56 | # To get a project ready to deploy with recap, you'll need to install the gem, most likely by adding 57 | # an entry like the following to the `Gemfile`, then running `bundle install`. 58 | # 59 | #
gem 'recap', '~>1.0.0'
60 | # 61 | # Once the gem is installed, generate a `Capfile` by running `recap setup` within your project 62 | # folder. You can see the supported options with `recap help setup`. The generated `Capfile` 63 | # will look something like this: 64 | # 65 | #
require 'recap/recipes/rails'
 66 | #
 67 | # set :application, 'example-app'
 68 | # set :repository, 'git@example.com:example/example-app.git'
 69 | #
 70 | # server 'server.example.com', :app
71 | # 72 | # Edit the `Capfile` to point at your deployment server and your project should be ready. `cap -T` 73 | # will show all the tasks now available. 74 | # 75 | # #### Preparing the server 76 | # 77 | # The next step is setting up the server. Running `cap bootstrap` will check your personal account 78 | # on the server is configured correctly, and add an account for your application. 79 | # 80 | # This application account is dedicated to your app, so you can edit its `.profile` as much as you 81 | # need (to add a particular version of `ruby` to the path, for example). 82 | # [recap/tasks/env.rb](recap/tasks/env.html) information on how to use the `env:set` and `env:edit` 83 | # tasks to set configuration variables. 84 | # 85 | # #### Preparing the app 86 | # 87 | # Running `cap deploy:setup` clones your code and sets up everything ready for the first deployment. 88 | # Once this has been run, you might want to set up a virtual host entry for nginx or Apache to 89 | # point at your app. 90 | # 91 | # #### Deploying 92 | # 93 | # Finally running `cap deploy` will deploy your app for the first time. Each time you make a change 94 | # you want deployed, commit and push your changes to your `git` repository, and run `cap deploy` to 95 | # push those changes to the server. 96 | # 97 | # ### Further information 98 | # 99 | # Recap has recipes to deploy static, ruby-based and rails apps which you can find out about in 100 | # [recap/recipes](recap/recipes.html). 101 | # 102 | # For more information about all the capistrano tasks recap provides, see 103 | # [recap/tasks](recap/tasks.html). 104 | # 105 | # ### Versioning and License ### 106 | # 107 | # recap uses [semantic versioning](http://semver.org/). 108 | # The code is available [on github](https://github.com/freerange/recap) and released under the 109 | # [MIT License](https://github.com/freerange/recap/blob/master/LICENSE) 110 | 111 | module Recap 112 | module Support 113 | autoload :Compatibility, 'recap/support/compatibility' 114 | autoload :Namespace, 'recap/support/namespace' 115 | autoload :Environment, 'recap/support/environment' 116 | autoload :ShellCommand, 'recap/support/shell_command' 117 | autoload :CLI, 'recap/support/cli' 118 | end 119 | 120 | autoload :Version, 'recap/version' 121 | end 122 | -------------------------------------------------------------------------------- /features/support/project.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | require 'faker' 3 | require 'erb' 4 | 5 | module ProjectSupport 6 | def project 7 | @project 8 | end 9 | 10 | def start_project(options = {}) 11 | @project = Project.new(options) 12 | end 13 | 14 | class Template 15 | def initialize(template_name) 16 | @template_name = template_name 17 | end 18 | 19 | def template_root 20 | File.expand_path("../../templates/", __FILE__) 21 | end 22 | 23 | def to_s 24 | ERB.new(read_template).result(binding) 25 | end 26 | 27 | def write_to(path) 28 | full_path = File.expand_path(path) 29 | FileUtils.mkdir_p File.dirname(full_path) 30 | File.write(full_path, to_s) 31 | end 32 | 33 | def read_template 34 | template_path = File.join(template_root, @template_name) 35 | File.read(template_path) 36 | end 37 | end 38 | 39 | class Capfile < Template 40 | attr_reader :project, :recap_require 41 | 42 | def initialize(project, options = {}) 43 | super('project/Capfile.erb') 44 | @project = project 45 | @recap_require = options[:recap_require] || 'recap/recipes/static' 46 | end 47 | 48 | def default_environment_values 49 | @default_environment_values ||= {} 50 | end 51 | end 52 | 53 | class Gemfile < Template 54 | attr_accessor :foreman 55 | attr_accessor :gems 56 | 57 | def initialize(gems = {}) 58 | super('project/Gemfile.erb') 59 | @gems = gems 60 | end 61 | end 62 | 63 | class Procfile < Template 64 | attr_reader :name, :command 65 | 66 | def initialize(name, command) 67 | super('project/Procfile.erb') 68 | @name = name 69 | @command = command 70 | end 71 | end 72 | 73 | class BundledGem 74 | def initialize(gem, version) 75 | @gem = gem 76 | @version = version 77 | @output_path = File.expand_path("../../../test-vm/share/gems/#{gem}", __FILE__) 78 | end 79 | 80 | def generate 81 | FileUtils.mkdir_p @output_path 82 | FileUtils.chdir @output_path do 83 | GemBinary.new(@gem, @version).write_to "bin/#{@gem}" 84 | Gemspec.new(@gem, @version).write_to "#{@name}.gemspec" 85 | 86 | `git init` 87 | `git add --all` 88 | `git commit -m 'Committed version #{@version}'` 89 | `git tag #{@version}` 90 | end 91 | end 92 | 93 | class Gemspec < Template 94 | attr_reader :gem, :version 95 | 96 | def initialize(gem, version) 97 | super 'gem/gemspec.erb' 98 | @gem = gem 99 | @version = version 100 | end 101 | end 102 | 103 | class GemBinary < Template 104 | attr_reader :gem, :version 105 | 106 | def initialize(name, version) 107 | super 'gem/binary.erb' 108 | @gem = name 109 | @version = version 110 | end 111 | end 112 | end 113 | 114 | class Project 115 | def initialize(options = {}) 116 | @server = options[:server] 117 | @gems = {} 118 | FileUtils.rm_rf repository_path 119 | git 'init' 120 | write_and_commit_file 'Capfile', Capfile.new(self, options[:capfile] || {}) 121 | write_and_commit_file 'log/.gitkeep' 122 | end 123 | 124 | def name 125 | @name ||= Faker::Name.first_name.downcase 126 | end 127 | 128 | def private_key_path 129 | @server.private_key_path 130 | end 131 | 132 | def latest_version 133 | committed_versions[0] 134 | end 135 | 136 | def previous_version 137 | committed_versions[1] 138 | end 139 | 140 | def committed_versions 141 | `cd #{repository_path} && git log --pretty=format:"%H"`.split("\n") 142 | end 143 | 144 | def write_and_commit_file(path, content = "") 145 | write_file(path, content) 146 | commit_files(path) 147 | end 148 | 149 | def read_file(path) 150 | full_path = File.join(repository_path, path) 151 | File.read(full_path) 152 | end 153 | 154 | def write_file(path, content = "") 155 | full_path = File.join(repository_path, path) 156 | FileUtils.mkdir_p File.dirname(full_path) 157 | File.write(full_path, content) 158 | end 159 | 160 | def commit_files(*paths) 161 | git "add #{paths.join(' ')}" 162 | git "commit -m 'Added #{paths.join(' ')}'" 163 | end 164 | 165 | def repository_path(path = "") 166 | File.join('test-vm/share/projects/', name, path) 167 | end 168 | 169 | def deployment_path(path = "") 170 | File.join("/home/#{name}/app", path) 171 | end 172 | 173 | def deployed_version 174 | (@server.run "cd #{deployment_path} && git rev-parse HEAD").strip 175 | end 176 | 177 | def run_cap(command) 178 | `cap -l capistrano.log -f #{repository_path('Capfile')} #{command}` 179 | raise "Exit code returned running 'cap #{command}'" if $?.exitstatus != 0 180 | end 181 | 182 | def run_on_server(cmd, path = deployment_path) 183 | @server.run("cd #{path} && #{cmd}") 184 | end 185 | 186 | def git(command) 187 | FileUtils.mkdir_p repository_path 188 | FileUtils.chdir repository_path do 189 | `git #{command}` 190 | end 191 | end 192 | 193 | def gemfile 194 | @gemfile ||= Gemfile.new 195 | end 196 | 197 | def add_gem_to_bundle(gem, version) 198 | gemfile.gems[gem] = version 199 | BundledGem.new(gem, version).generate 200 | regenerate_bundle 201 | end 202 | 203 | def add_foreman_to_bundle 204 | gemfile.foreman = true 205 | regenerate_bundle 206 | end 207 | 208 | def regenerate_bundle 209 | write_and_commit_file 'Gemfile', gemfile 210 | # Nasty hack to generate a Gemfile.lock 211 | @server.run "cd /recap/share/projects/#{name} && /opt/ruby/bin/bundle install" 212 | commit_files 'Gemfile.lock' 213 | end 214 | 215 | def add_command_to_procfile(name, command) 216 | write_and_commit_file 'Procfile', Procfile.new(name, command) 217 | end 218 | 219 | def add_default_env_value_to_capfile(name, value) 220 | content = read_file('Capfile') 221 | content << "\nset_default_env '#{name}', '#{value}'" 222 | write_and_commit_file('Capfile', content) 223 | end 224 | 225 | def commit_changes 226 | write_and_commit_file 'project-file', Faker::Lorem.sentence 227 | end 228 | end 229 | end 230 | 231 | World(ProjectSupport) 232 | -------------------------------------------------------------------------------- /spec/tasks/bundler_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'recap/tasks/bundler' 3 | 4 | describe Recap::Tasks::Bundler do 5 | let :config do 6 | Capistrano::Configuration.new 7 | end 8 | 9 | let :namespace do 10 | config.bundle 11 | end 12 | 13 | let :deploy_to do 14 | 'path/to/deploy/to' 15 | end 16 | 17 | before do 18 | config.set :deploy_to, deploy_to 19 | Recap::Tasks::Bundler.load_into(config) 20 | end 21 | 22 | describe 'Settings' do 23 | describe '#bundle_gemfile' do 24 | it 'defaults to Gemfile' do 25 | config.bundle_gemfile.should eql('Gemfile') 26 | end 27 | end 28 | 29 | describe '#bundle_gemfile_lock' do 30 | it 'defaults to bundle_gemfile + .lock' do 31 | config.set :bundle_gemfile, 'custom/Gemfile' 32 | config.bundle_gemfile_lock.should eql('custom/Gemfile.lock') 33 | end 34 | end 35 | 36 | describe '#bundle_path' do 37 | it 'defaults to deploy_to + /vendor/gems' do 38 | config.bundle_path.should eql(deploy_to + '/vendor/gems') 39 | end 40 | end 41 | 42 | describe '#bundle_without' do 43 | it 'defaults to development and test groups' do 44 | config.bundle_without.should eql("development test") 45 | end 46 | end 47 | 48 | describe '#bundle_install_command' do 49 | it 'takes --gemfile from the bundle_gemfile setting' do 50 | config.set :bundle_gemfile, 'path/to/bundle/Gemfile' 51 | config.bundle_install_command.include?(" --gemfile path/to/bundle/Gemfile ").should be_true 52 | end 53 | 54 | it 'takes --path from the bundle_path setting' do 55 | config.set :bundle_path, 'path/to/install/gems' 56 | config.bundle_install_command.include?(" --path path/to/install/gems ").should be_true 57 | end 58 | 59 | it 'takes --without from the bundle_without setting' do 60 | config.set :bundle_without, 'groups to skip' 61 | config.bundle_install_command.include?(" --without groups to skip").should be_true 62 | end 63 | 64 | it 'includes --deployment flag to ensure Gemfile.lock exists' do 65 | config.bundle_install_command.include?(" --deployment ").should be_true 66 | end 67 | 68 | it 'includes --binstubs flag to generate binary stubs used by other tasks' do 69 | config.bundle_install_command.include?(" --binstubs ").should be_true 70 | end 71 | 72 | it 'includes --quiet flag to reduce uneccessary noise' do 73 | config.bundle_install_command.include?(" --quiet ").should be_true 74 | end 75 | end 76 | end 77 | 78 | describe 'Tasks' do 79 | describe 'bundle:install' do 80 | it 'run bundle_install_command as the app if the Gemfile and Gemfile.lock exist' do 81 | namespace.stubs(:deployed_file_exists?).with(config.bundle_gemfile).returns(true) 82 | namespace.stubs(:deployed_file_exists?).with(config.bundle_gemfile_lock).returns(true) 83 | namespace.expects(:as_app).with(config.bundle_install_command) 84 | 85 | config.find_and_execute_task('bundle:install') 86 | end 87 | 88 | it 'skips bundle_install if the Gemfile missing' do 89 | namespace.stubs(:deployed_file_exists?).with(config.bundle_gemfile).returns(false) 90 | namespace.expects(:as_app).never 91 | 92 | config.find_and_execute_task('bundle:install') 93 | end 94 | 95 | it 'aborts with warning if Gemfile exists but Gemfile.lock doesn\'t' do 96 | namespace.stubs(:deployed_file_exists?).with(config.bundle_gemfile).returns(true) 97 | namespace.stubs(:deployed_file_exists?).with(config.bundle_gemfile_lock).returns(false) 98 | expected_message = 'Gemfile found without Gemfile.lock. The Gemfile.lock should be committed to the project repository' 99 | namespace.install.expects(:abort).with(expected_message) 100 | namespace.find_and_execute_task('bundle:install') 101 | end 102 | end 103 | 104 | describe 'bundle:install:if_changed' do 105 | it 'calls bundle:install:default if the Gemfile.lock has changed' do 106 | namespace.stubs(:trigger_update?).with(config.bundle_gemfile).returns(false) 107 | namespace.stubs(:trigger_update?).with(config.bundle_gemfile_lock).returns(true) 108 | namespace.install.expects(:default) 109 | config.find_and_execute_task('bundle:install:if_changed') 110 | end 111 | 112 | it 'calls bundle:install:default if the Gemfile has changed' do 113 | namespace.stubs(:trigger_update?).with(config.bundle_gemfile).returns(true) 114 | namespace.stubs(:trigger_update?).with(config.bundle_gemfile_lock).returns(false) 115 | namespace.install.expects(:default) 116 | config.find_and_execute_task('bundle:install:if_changed') 117 | end 118 | 119 | it 'skips bundle_install if neither Gemfile nor Gemfile.lock have changed' do 120 | namespace.stubs(:trigger_update?).with(config.bundle_gemfile).returns(false) 121 | namespace.stubs(:trigger_update?).with(config.bundle_gemfile_lock).returns(false) 122 | namespace.install.expects(:default).never 123 | config.find_and_execute_task('bundle:install:if_changed') 124 | end 125 | end 126 | 127 | describe 'bundle:check_installed' do 128 | before do 129 | config.set(:application_user, 'fred') 130 | end 131 | 132 | it 'checks to see whether bundler is installed' do 133 | namespace.expects(:exit_code_as_app).with('bundle --version', '.').returns("0") 134 | config.find_and_execute_task('bundle:check_installed') 135 | end 136 | 137 | it 'aborts with explanation if bundler command fails' do 138 | namespace.stubs(:exit_code_as_app).returns("1") 139 | namespace.expects(:abort).with("The application user 'fred' cannot execute `bundle`. Please check you have bundler installed.") 140 | config.find_and_execute_task('bundle:check_installed') 141 | end 142 | end 143 | end 144 | 145 | describe 'Callbacks' do 146 | describe 'bundle:check_installed' do 147 | before do 148 | Recap::Tasks::Preflight.load_into(config) 149 | end 150 | 151 | it 'runs after `preflight:check`' do 152 | config.stubs(:find_and_execute_task) 153 | config.expects(:find_and_execute_task).with('bundle:check_installed') 154 | config.trigger :after, config.find_task('preflight:check') 155 | end 156 | end 157 | end 158 | end -------------------------------------------------------------------------------- /spec/tasks/rails_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'recap/tasks/rails' 3 | 4 | describe Recap::Tasks::Rails do 5 | let :config do 6 | Capistrano::Configuration.new 7 | end 8 | 9 | let :namespace do 10 | config.rails 11 | end 12 | 13 | let :deploy_to do 14 | 'path/to/deploy/to' 15 | end 16 | 17 | before do 18 | config.set :deploy_to, deploy_to 19 | config.set :force_full_deploy, :to => false 20 | config.stubs(:set_default_env) 21 | Recap::Tasks::Rails.load_into(config) 22 | end 23 | 24 | describe 'Settings' do 25 | describe '#asset_precompilation_triggers' do 26 | it 'includes app/assets, vendor/assets, Gemfile.lock and config' do 27 | namespace.asset_precompilation_triggers.include?('app/assets').should be_true 28 | namespace.asset_precompilation_triggers.include?('vendor/assets').should be_true 29 | namespace.asset_precompilation_triggers.include?('Gemfile.lock').should be_true 30 | namespace.asset_precompilation_triggers.include?('config').should be_true 31 | end 32 | end 33 | end 34 | 35 | describe 'Tasks' do 36 | describe 'rails:db:load_schema' do 37 | it 'loads the schema if db/schema.rb exists' do 38 | namespace.stubs(:deployed_file_exists?).with('db/schema.rb').returns(true) 39 | namespace.expects(:as_app_once).with('./bin/rake db:create db:schema:load') 40 | config.find_and_execute_task('rails:db:load_schema') 41 | end 42 | 43 | it 'loads the structure if db/schema.rb does not exist but db/structure.sql does' do 44 | namespace.stubs(:deployed_file_exists?).with('db/schema.rb').returns(false) 45 | namespace.stubs(:deployed_file_exists?).with('db/structure.sql').returns(true) 46 | namespace.expects(:as_app_once).with('./bin/rake db:create db:structure:load') 47 | config.find_and_execute_task('rails:db:load_schema') 48 | end 49 | 50 | it 'does nothing if neither db/schema.rb nor db/structure.sql exists' do 51 | namespace.stubs(:deployed_file_exists?).with('db/schema.rb').returns(false) 52 | namespace.stubs(:deployed_file_exists?).with('db/structure.sql').returns(false) 53 | namespace.expects(:as_app_once).never 54 | config.find_and_execute_task('rails:db:load_schema') 55 | end 56 | 57 | end 58 | 59 | describe 'rails:db:migrate' do 60 | it 'runs migrations if schema.rb has changed' do 61 | namespace.stubs(:deployed_file_exists?).with('db/schema.rb').returns(true) 62 | namespace.stubs(:changed_files).returns(["db/schema.rb"]) 63 | namespace.expects(:as_app_once).with('./bin/rake db:migrate') 64 | config.find_and_execute_task('rails:db:migrate') 65 | end 66 | 67 | it 'runs migrations if structure.sql has changed' do 68 | namespace.stubs(:deployed_file_exists?).with('db/schema.rb').returns(false) 69 | namespace.stubs(:deployed_file_exists?).with('db/structure.sql').returns(true) 70 | namespace.stubs(:changed_files).returns(["db/structure.sql"]) 71 | namespace.expects(:as_app_once).with('./bin/rake db:migrate') 72 | config.find_and_execute_task('rails:db:migrate') 73 | end 74 | 75 | it 'runs migrations if migrations have changed' do 76 | namespace.stubs(:deployed_file_exists?).with('db/schema.rb').returns(true) 77 | namespace.stubs(:changed_files).returns(["db/migrate/12345_migrations.rb"]) 78 | namespace.expects(:as_app_once).with('./bin/rake db:migrate') 79 | config.find_and_execute_task('rails:db:migrate') 80 | end 81 | 82 | it 'runs migrations if migrations have changed (using structure)' do 83 | namespace.stubs(:deployed_file_exists?).with('db/schema.rb').returns(false) 84 | namespace.stubs(:deployed_file_exists?).with('db/structure.sql').returns(true) 85 | namespace.stubs(:changed_files).returns(["db/migrate/12345_migrations.rb"]) 86 | namespace.expects(:as_app_once).with('./bin/rake db:migrate') 87 | config.find_and_execute_task('rails:db:migrate') 88 | end 89 | 90 | it 'does nothing if the migrations have not changed' do 91 | namespace.stubs(:deployed_file_exists?).with('db/schema.rb').returns(true) 92 | namespace.stubs(:trigger_update?).with('db/').returns(false) 93 | namespace.expects(:as_app_once).never 94 | config.find_and_execute_task('rails:db:migrate') 95 | end 96 | 97 | it 'does nothing if the migrations have not changed (using structure)' do 98 | namespace.stubs(:deployed_file_exists?).with('db/schema.rb').returns(false) 99 | namespace.stubs(:deployed_file_exists?).with('db/structure.sql').returns(true) 100 | namespace.stubs(:trigger_update?).with('db/').returns(false) 101 | namespace.expects(:as_app_once).never 102 | config.find_and_execute_task('rails:db:migrate') 103 | end 104 | 105 | it 'does nothing if both the schema and the structure do not exist' do 106 | namespace.stubs(:deployed_file_exists?).with('db/schema.rb').returns(false) 107 | namespace.stubs(:deployed_file_exists?).with('db/structure.sql').returns(false) 108 | namespace.expects(:as_app).never 109 | config.find_and_execute_task('rails:db:migrate') 110 | end 111 | end 112 | 113 | describe 'assets:precompile:if_changed' do 114 | it 'calls assets:precompileassets:precompile if any of the triggers have changed' do 115 | config.set(:asset_precompilation_triggers, ['trigger-one', 'trigger-two']) 116 | namespace.stubs(:trigger_update?).with('trigger-one').returns(false) 117 | namespace.stubs(:trigger_update?).with('trigger-two').returns(true) 118 | namespace.assets.precompile.expects(:default) 119 | config.find_and_execute_task('rails:assets:precompile:if_changed') 120 | end 121 | 122 | it 'skips assets:precompile if none of the triggers have changed' do 123 | config.set(:asset_precompilation_triggers, ['trigger-one', 'trigger-two']) 124 | namespace.stubs(:trigger_update?).returns(false) 125 | namespace.assets.expects(:default).never 126 | config.find_and_execute_task('rails:assets:precompile:if_changed') 127 | end 128 | end 129 | 130 | describe 'assets:precompile' do 131 | it 'compiles assets on the server' do 132 | namespace.expects(:as_app).with('./bin/rake RAILS_GROUPS=assets assets:precompile') 133 | config.find_and_execute_task('rails:assets:precompile') 134 | end 135 | end 136 | end 137 | 138 | describe 'Callbacks' do 139 | before do 140 | Recap::Tasks::Deploy.load_into(config) 141 | end 142 | 143 | it 'runs `rails:db:migrate` after `deploy:update_code`' do 144 | config.stubs(:find_and_execute_task) 145 | config.expects(:find_and_execute_task).with('rails:db:migrate') 146 | config.trigger :after, config.find_task('deploy:update_code') 147 | end 148 | 149 | it 'runs `rails:assets:precompile` after `deploy:update_code`' do 150 | config.stubs(:find_and_execute_task) 151 | config.expects(:find_and_execute_task).with('rails:assets:precompile:if_changed') 152 | config.trigger :after, config.find_task('deploy:update_code') 153 | end 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /spec/tasks/foreman_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'recap/tasks/foreman' 3 | 4 | describe Recap::Tasks::Foreman do 5 | let :config do 6 | Capistrano::Configuration.new 7 | end 8 | 9 | let :namespace do 10 | config.foreman 11 | end 12 | 13 | let :deploy_to do 14 | 'path/to/deploy/to' 15 | end 16 | 17 | let :commands do 18 | sequence('commands') 19 | end 20 | 21 | before do 22 | config.set :application, 'example-app' 23 | config.set :application_user, 'example-app-user' 24 | config.set :deploy_to, deploy_to 25 | Recap::Tasks::Foreman.load_into(config) 26 | end 27 | 28 | describe 'Settings' do 29 | describe '#procfile' do 30 | it 'defaults Procfile' do 31 | config.procfile.should eql('Procfile') 32 | end 33 | end 34 | 35 | describe '#foreman_export_format' do 36 | it 'defaults to upstart' do 37 | config.foreman_export_format.should eql('upstart') 38 | end 39 | end 40 | 41 | describe '#foreman_template' do 42 | it 'defaults to nil' do 43 | config.foreman_template.should be_nil 44 | end 45 | end 46 | 47 | describe '#foreman_template_option' do 48 | it 'is nil if foreman_template is unset' do 49 | config.foreman_template_option.should be_nil 50 | end 51 | 52 | it 'points at the foreman_template if set' do 53 | config.set :foreman_template, '/path/to/template' 54 | config.foreman_template_option.should eql('--template /path/to/template') 55 | end 56 | end 57 | 58 | describe '#foreman_export_location' do 59 | it 'defaults to /etc/init' do 60 | config.foreman_export_location.should eql('/etc/init') 61 | end 62 | end 63 | 64 | describe '#foreman_tmp_location' do 65 | it 'defaults to deploy_to + /tmp/foreman' do 66 | config.foreman_tmp_location.should eql(deploy_to + '/tmp/foreman') 67 | end 68 | end 69 | 70 | describe '#foreman_export_command' do 71 | before :each do 72 | config.set :foreman_template_option, '' 73 | config.set :foreman_export_format, '' 74 | config.set :foreman_tmp_location, '' 75 | end 76 | 77 | it 'starts by exporting to the tmp location in the export format' do 78 | config.foreman_export_command.index('./bin/foreman export ').should eql(0) 79 | end 80 | 81 | it 'includes --procfile option pointing to procfile' do 82 | config.set :procfile, '/custom/procfile/location' 83 | config.foreman_export_command.index("--procfile /custom/procfile/location").should_not be_nil 84 | end 85 | 86 | it 'includes --app option naming application' do 87 | config.set :application, 'my-application' 88 | config.foreman_export_command.index("--app my-application").should_not be_nil 89 | end 90 | 91 | it 'includes --user option pointing to procfile' do 92 | config.set :application_user, 'my-application-user' 93 | config.foreman_export_command.index("--user my-application-user").should_not be_nil 94 | end 95 | 96 | it 'includes --log option pointing to log location' do 97 | config.set :deploy_to, '/custom/deploy/location' 98 | config.foreman_export_command.index("--log /custom/deploy/location/log").should_not be_nil 99 | end 100 | 101 | it 'includes --template option if set' do 102 | config.set :foreman_template_option, '--template /path/to/template' 103 | config.foreman_export_command.index("--template /path/to/template").should_not be_nil 104 | end 105 | end 106 | end 107 | 108 | describe 'Tasks' do 109 | describe 'foreman:export:if_changed' do 110 | before :each do 111 | namespace.stubs(:trigger_update?).with(config.procfile).returns(false) 112 | end 113 | 114 | it 'calls foreman:export if the Procfile has changed' do 115 | namespace.stubs(:trigger_update?).with(config.procfile).returns(true) 116 | namespace.export.expects(:default) 117 | config.find_and_execute_task('foreman:export:if_changed') 118 | end 119 | 120 | it 'skips foreman:export if the Procfile has not changed' do 121 | namespace.export.expects(:default).never 122 | config.find_and_execute_task('foreman:export:if_changed') 123 | end 124 | 125 | describe 'foreman_template is set' do 126 | before :each do 127 | config.set :foreman_template, 'config/foreman/upstart' 128 | namespace.stubs(:trigger_update?).with(config.foreman_template).returns(false) 129 | end 130 | 131 | it 'calls foreman:export if any of the templates have changed' do 132 | namespace.stubs(:trigger_update?).with(config.foreman_template).returns(true) 133 | namespace.export.expects(:default) 134 | config.find_and_execute_task('foreman:export:if_changed') 135 | end 136 | end 137 | end 138 | 139 | describe 'foreman:export' do 140 | it 'runs the foreman export command, then moves the exported files to the export location' do 141 | namespace.stubs(:deployed_file_exists?).with(config.procfile).returns(true) 142 | namespace.expects(:sudo).with("mkdir -p #{config.deploy_to}/log").in_sequence(commands) 143 | namespace.expects(:sudo).with("chown #{config.application_user}: #{config.deploy_to}/log").in_sequence(commands) 144 | namespace.expects(:as_app).with(config.foreman_export_command).in_sequence(commands) 145 | namespace.expects(:sudo).with("rm -f #{config.foreman_export_location}/#{config.application}*").in_sequence(commands) 146 | namespace.expects(:sudo).with("cp #{config.foreman_tmp_location}/* #{config.foreman_export_location}").in_sequence(commands) 147 | config.find_and_execute_task('foreman:export') 148 | end 149 | 150 | it 'does nothing if no Procfile exists' do 151 | namespace.stubs(:deployed_file_exists?).with(config.procfile).returns(false) 152 | namespace.expects(:as_app).never 153 | namespace.expects(:sudo).never 154 | config.find_and_execute_task('foreman:export') 155 | end 156 | end 157 | 158 | describe 'foreman:start' do 159 | it 'starts the application' do 160 | namespace.stubs(:deployed_file_exists?).with(config.procfile).returns(true) 161 | namespace.expects(:sudo).with('start example-app') 162 | config.find_and_execute_task('foreman:start') 163 | end 164 | 165 | it 'does nothing if no Procfile exists' do 166 | namespace.stubs(:deployed_file_exists?).with(config.procfile).returns(false) 167 | namespace.expects(:sudo).never 168 | config.find_and_execute_task('foreman:start') 169 | end 170 | end 171 | 172 | describe 'foreman:stop' do 173 | it 'starts the application' do 174 | namespace.stubs(:deployed_file_exists?).with(config.procfile).returns(true) 175 | namespace.expects(:sudo).with('stop example-app') 176 | config.find_and_execute_task('foreman:stop') 177 | end 178 | 179 | it 'does nothing if no Procfile exists' do 180 | namespace.stubs(:deployed_file_exists?).with(config.procfile).returns(false) 181 | namespace.expects(:sudo).never 182 | config.find_and_execute_task('foreman:stop') 183 | end 184 | end 185 | 186 | describe 'foreman:restart' do 187 | it 'restart or starts the application' do 188 | namespace.stubs(:deployed_file_exists?).with(config.procfile).returns(true) 189 | namespace.expects(:sudo).with('restart example-app || sudo start example-app') 190 | config.find_and_execute_task('foreman:restart') 191 | end 192 | 193 | it 'does nothing if no Procfile exists' do 194 | namespace.stubs(:deployed_file_exists?).with(config.procfile).returns(false) 195 | namespace.expects(:sudo).never 196 | config.find_and_execute_task('foreman:restart') 197 | end 198 | end 199 | end 200 | end 201 | -------------------------------------------------------------------------------- /lib/recap/tasks/deploy.rb: -------------------------------------------------------------------------------- 1 | # These tasks provide the basic mechanism getting new code onto servers using git. 2 | 3 | require 'recap/tasks' 4 | require 'recap/support/capistrano_extensions' 5 | 6 | # These deployment tasks are designed to work alongside the tasks for 7 | # [altering environment variables](env.html), as well as the 8 | # [preflight checks](preflight.html) and 9 | # [bootstrap tasks](bootstrap.html). 10 | 11 | require 'recap/tasks/env' 12 | require 'recap/tasks/preflight' 13 | require 'recap/tasks/bootstrap' 14 | 15 | module Recap::Tasks::Deploy 16 | extend Recap::Support::Namespace 17 | 18 | namespace :deploy do 19 | # To use this recipe, both the application's name and its git repository are required. 20 | _cset(:application) { abort "You must set the name of your application in your Capfile, e.g.: set :application, 'tomafro.net'" } 21 | _cset(:repository) { abort "You must set the git respository location in your Capfile, e.g.: set :respository, 'git@github.com/tomafro/tomafro.net'" } 22 | 23 | # The recipe assumes that the application code will be run as a dedicated user. Any user who 24 | # can deploy the application should be added as a member of the application's group. By default, 25 | # both the application user and group take the same name as the application. 26 | _cset(:application_user) { application } 27 | _cset(:application_group) { application_user } 28 | 29 | # Deployments can be made from any branch. `master` is used by default. 30 | _cset(:branch, 'master') 31 | 32 | # Unlike a standard capistrano deployment, all releases are stored directly in the `deploy_to` 33 | # directory. The default is `/home/#{application_user}/app`. 34 | _cset(:deploy_to) { "/home/#{application_user}/app" } 35 | 36 | # Each release is marked by a unique tag, generated with the current timestamp. This should 37 | # not be changed, as the format is matched in the list of tags to find deploy tags. 38 | _cset(:release_tag) { Time.now.utc.strftime("%Y%m%d%H%M%S") } 39 | 40 | # If `release_tag` is changed, then `release_matcher` must be too, to a regular expression 41 | # that will match all generated release tags. In general it's best to leave both unchanged. 42 | _cset(:release_matcher) { /\A[0-9]{14}\Z/ } 43 | 44 | # On tagging a release, a message is also recorded alongside the tag. This message can contain 45 | # anything useful - its contents are not important for the recipe. 46 | _cset(:release_message, "Deployed at #{Time.now}") 47 | 48 | # Some tasks need to know the `latest_tag` - the most recent successful deployment. If no 49 | # deployments have been made, this will be `nil`. 50 | _cset(:latest_tag) { latest_tag_from_repository } 51 | 52 | # Force a complete deploy, even if no trigger files have changed 53 | _cset(:force_full_deploy, false) 54 | 55 | # A lock file is used to ensure deployments don't overlap 56 | _cset(:deploy_lock_file) { "#{deploy_to}/.recap-lock"} 57 | 58 | # The lock file is set to include a message that can be displayed 59 | # if claiming the lock fails 60 | _cset(:deploy_lock_message) { "Deployment in progress (started #{Time.now.to_s})" } 61 | 62 | # To authenticate with github or other git servers, it is easier (and cleaner) to forward the 63 | # deploying user's ssh key than manage keys on deployment servers. 64 | ssh_options[:forward_agent] = true 65 | 66 | # If key forwarding isn't possible, git may show a password prompt which stalls capistrano unless 67 | # `:pty` is set to `true`. 68 | default_run_options[:pty] = true 69 | 70 | # The `deploy:setup` task prepares all the servers for the deployment. It ensures the `env` 71 | # has been set, and clones the code. 72 | desc "Prepare servers for deployment" 73 | task :setup, :except => {:no_release => true} do 74 | transaction do 75 | top.env.set 76 | clone_code 77 | end 78 | end 79 | 80 | # The `deploy:clone_code` task clones the project repository into the `deploy_to` location 81 | # and ensures it has the correct file permissions. It shouldn't be necessary to call this 82 | # task manually as it is run as part of `deploy:setup`. 83 | task :clone_code, :except => {:no_release => true} do 84 | on_rollback { as_app "rm -fr #{deploy_to}" } 85 | # Before cloning, the directory needs to exist and be both readable and writeable by the application group 86 | as_app "mkdir -p #{deploy_to}", "~" 87 | as_app "chmod g+rw #{deploy_to}" 88 | # Then clone the code and change to the given branch 89 | unless deployed_dir_exists?(".git") 90 | git "clone #{repository} ." 91 | git "reset --hard origin/#{branch}" 92 | end 93 | end 94 | 95 | # The `deploy` task ensures the environment is set, updates the application code, 96 | # tags the release and restarts the application. 97 | desc "Deploy the latest application code" 98 | task :default do 99 | transaction_with_lock deploy_lock_message do 100 | top.env.set 101 | update_code 102 | tag 103 | end 104 | restart 105 | end 106 | 107 | # `deploy:full` rung a standard deploy, but sets the `force_full_deploy` flag 108 | # first. Any parts of the deploy that would normally only be triggered if 109 | # a file changes will always run. 110 | task :full do 111 | set(:force_full_deploy, true) 112 | default 113 | end 114 | 115 | # Fetch the latest changes, then update `HEAD` to the deployment branch. 116 | task :update_code, :except => {:no_release => true} do 117 | on_rollback { git "reset --hard #{latest_tag}" if latest_tag } 118 | git "fetch" 119 | git "reset --hard origin/#{branch}" 120 | end 121 | 122 | # Tag `HEAD` with the release tag and message 123 | task :tag, :except => {:no_release => true} do 124 | unless release_tag =~ release_matcher 125 | abort "The release_tag must be matched by the release_matcher regex, #{release_tag} doesn't match #{release_matcher}" 126 | end 127 | on_rollback { git "tag -d #{release_tag}" } 128 | git "tag #{release_tag} -m '#{release_message}'" 129 | end 130 | 131 | # After a successful deployment, the app is restarted. In the most basic deployments this does 132 | # nothing, but other recipes may override it, or attach tasks to its before or after hooks. 133 | desc "Restart the application following a deploy" 134 | task :restart do 135 | end 136 | 137 | # To rollback a release, the latest tag is deleted, and `HEAD` reset to the previous release 138 | # (if one exists). Finally the application is restarted again. 139 | desc "Rollback to the previous release" 140 | namespace :rollback do 141 | task :default do 142 | if latest_tag 143 | git "tag -d #{latest_tag}" 144 | if previous_tag = latest_tag_from_repository 145 | git "reset --hard #{previous_tag}" 146 | end 147 | restart 148 | else 149 | abort "This app is not currently deployed" 150 | end 151 | end 152 | end 153 | 154 | # The `destroy` task can be used in an emergency or when manually testing deployment. It removes 155 | # all previously deployed files, leaving a blank slate to run `deploy:setup` on. 156 | desc "Remove all deployed files" 157 | task :destroy do 158 | sudo "rm -rf #{deploy_to}" 159 | end 160 | 161 | # As well as locking during each deployment, locks can manually be set with `deploy:lock`. To 162 | # use a custom lock message, do `DEPLOY_LOCK_MESSAGE="My message" cap deploy:lock`. Locking 163 | # prevents deployments, but not other tasks. 164 | desc "Lock deployments. Use the DEPLOY_LOCK_MESSAGE enviornment variable to set a custom message" 165 | task :lock do 166 | claim_lock ENV['DEPLOY_LOCK_MESSAGE'] || "Manually locked at #{Time.now}" 167 | end 168 | 169 | # To unlock a manually set lock, or a lock that has been left behind in error, the `deploy:unlock` 170 | # task can be used. 171 | desc "Unlock deployments" 172 | task :unlock do 173 | release_lock 174 | end 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /spec/tasks/deploy_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'recap/tasks/deploy' 3 | 4 | describe Recap::Tasks::Deploy do 5 | let :config do 6 | Capistrano::Configuration.new 7 | end 8 | 9 | let :namespace do 10 | config.deploy 11 | end 12 | 13 | let :commands do 14 | sequence('commands') 15 | end 16 | 17 | before do 18 | Recap::Tasks::Deploy.load_into(config) 19 | end 20 | 21 | it 'configures capistrano to use ssh key forwarding' do 22 | config.ssh_options[:forward_agent].should be_true 23 | end 24 | 25 | it 'configures capistrano to use a pty session when running commands' do 26 | config.default_run_options[:pty].should be_true 27 | end 28 | 29 | describe 'Settings' do 30 | describe '#application' do 31 | it 'exits if accessed before being set' do 32 | namespace.expects(:abort).with(regexp_matches(/You must set the name of your application in your Capfile/)) 33 | config.application 34 | end 35 | end 36 | 37 | describe '#repository' do 38 | it 'exits if accessed before being set' do 39 | namespace.expects(:abort).with(regexp_matches(/You must set the git respository location in your Capfile/)) 40 | config.repository 41 | end 42 | end 43 | 44 | describe '#application_user' do 45 | it 'defaults to the name of the application' do 46 | config.set :application, 'rabbitfoot' 47 | config.application_user.should eql('rabbitfoot') 48 | end 49 | end 50 | 51 | describe '#application_group' do 52 | it 'defaults to the name of the application user' do 53 | config.set :application_user, 'rabbitfoot' 54 | config.application_group.should eql('rabbitfoot') 55 | end 56 | end 57 | 58 | describe '#branch' do 59 | it 'defaults to master' do 60 | config.branch.should eql('master') 61 | end 62 | 63 | it 'honours value in pre-set variable' do 64 | config.set(:branch, 'branch-from-command-line') 65 | Recap::Tasks::Deploy.load_into(config) 66 | config.branch.should eql('branch-from-command-line') 67 | end 68 | end 69 | 70 | describe '#deploy_to' do 71 | it 'defaults to an `app` folder within the application user home directory' do 72 | config.set :application, 'hare' 73 | config.set :application_user, 'rabbitfoot' 74 | config.deploy_to.should eql('/home/rabbitfoot/app') 75 | end 76 | end 77 | 78 | describe '#release_tag' do 79 | it 'defaults to the current timestamp' do 80 | now = Time.now 81 | Time.stubs(:now).returns(now) 82 | config.release_tag.should eql(Time.now.utc.strftime("%Y%m%d%H%M%S")) 83 | end 84 | end 85 | 86 | describe '#release_matcher' do 87 | it 'defaults to a matcher matching timestamps' do 88 | ("20130908123422" =~ config.release_matcher).should be_true 89 | end 90 | 91 | it 'does not match timestamp-like numbers with too many digits' do 92 | ("201309081234221" =~ config.release_matcher).should be_false 93 | end 94 | 95 | it 'does not match timestamp-like numbers with too few digits' do 96 | ("2013090812342" =~ config.release_matcher).should be_false 97 | end 98 | 99 | it 'does not match strings with non-numeric characters' do 100 | ("2013090a123421" =~ config.release_matcher).should be_false 101 | end 102 | end 103 | 104 | describe '#latest_tag' do 105 | it 'memoizes call to latest_tag_from_repository' do 106 | namespace.stubs(:latest_tag_from_repository).returns('abc123').then.returns('something-else') 107 | config.latest_tag.should eql('abc123') 108 | config.latest_tag.should eql('abc123') 109 | end 110 | end 111 | end 112 | 113 | describe 'Tasks' do 114 | let :application do 115 | 'romulus' 116 | end 117 | 118 | let :repository do 119 | 'git@github.com/example/romulus.git' 120 | end 121 | 122 | let :deploy_to do 123 | '/path/to/deploy/romulus/into' 124 | end 125 | 126 | before do 127 | config.set :application, application 128 | config.set :repository, repository 129 | config.set :deploy_to, deploy_to 130 | end 131 | 132 | describe 'deploy:setup' do 133 | it 'runs env:set and deploy:clone_code tasks' do 134 | env = stub('env') 135 | config.stubs(:env).returns(env) 136 | env.expects('set') 137 | namespace.expects(:clone_code) 138 | config.find_and_execute_task('deploy:setup') 139 | end 140 | 141 | it 'calls deploy:clone_code task within a transaction' do 142 | namespace.stubs(:transaction) 143 | namespace.expects(:clone_code).never 144 | config.find_and_execute_task('deploy:setup') 145 | end 146 | 147 | it 'removes the deploy_to dir if a rollback is triggered' do 148 | config.stubs(:env).returns(stub_everything('env')) 149 | namespace.stubs(:as_app) 150 | namespace.expects(:as_app).with('rm -fr ' + deploy_to) 151 | namespace.stubs(:git).raises(RuntimeError) 152 | config.find_and_execute_task('deploy:setup') rescue RuntimeError 153 | end 154 | end 155 | 156 | describe 'deploy:clone_code' do 157 | it 'creates deploy_to dir, ensures it\'s group writable, then clones the repository into it' do 158 | config.set :branch, 'given-branch' 159 | 160 | namespace.expects(:as_app).with('mkdir -p ' + deploy_to, '~').in_sequence(commands) 161 | namespace.expects(:as_app).with('chmod g+rw ' + deploy_to).in_sequence(commands) 162 | namespace.expects(:deployed_dir_exists?).with('.git').in_sequence(commands) 163 | namespace.expects(:git).with('clone ' + repository + ' .').in_sequence(commands) 164 | namespace.expects(:git).with('reset --hard origin/given-branch').in_sequence(commands) 165 | config.find_and_execute_task('deploy:clone_code') 166 | end 167 | end 168 | 169 | describe 'deploy' do 170 | it 'runs env:set, deploy:update_code, deploy:tag and then deploy:restart tasks' do 171 | namespace.stubs(:transaction_with_lock).yields 172 | env = stub('env') 173 | config.stubs(:env).returns(env) 174 | env.expects('set') 175 | namespace.expects(:update_code).in_sequence(commands) 176 | namespace.expects(:tag).in_sequence(commands) 177 | namespace.expects(:restart).in_sequence(commands) 178 | config.find_and_execute_task('deploy') 179 | end 180 | 181 | it 'calls deploy:update_code task within a locked transaction' do 182 | namespace.stubs(:transaction_with_lock) 183 | namespace.expects(:update_code).never 184 | config.find_and_execute_task('deploy') 185 | end 186 | 187 | it 'calls deploy:tag task within a locked transaction' do 188 | namespace.stubs(:transaction_with_lock) 189 | namespace.expects(:tag).never 190 | config.find_and_execute_task('deploy') 191 | end 192 | 193 | it 'calls restart outside the transaction' do 194 | namespace.stubs(:transaction_with_lock) 195 | namespace.expects(:restart) 196 | config.find_and_execute_task('deploy') 197 | end 198 | end 199 | 200 | describe 'deploy:full' do 201 | it 'sets force_full_deploy and calls the default deployment task' do 202 | namespace.expects(:default) 203 | config.find_and_execute_task('deploy:full') 204 | namespace.force_full_deploy.should be_true 205 | end 206 | end 207 | 208 | describe 'deploy:update_code' do 209 | it 'fetches latest changes, then resets to repository branch' do 210 | config.set :branch, 'release-branch' 211 | namespace.expects(:git).with('fetch').in_sequence(commands) 212 | namespace.expects(:git).with('reset --hard origin/release-branch').in_sequence(commands) 213 | namespace.find_and_execute_task('deploy:update_code') 214 | end 215 | end 216 | 217 | describe 'deploy:tag' do 218 | before do 219 | config.set :release_tag, '20120101012034' 220 | config.set :release_message, 'Released into the wild' 221 | end 222 | 223 | it 'tags code with the release tag and release message' do 224 | namespace.expects(:git).with('tag 20120101012034 -m \'Released into the wild\'') 225 | namespace.find_and_execute_task('deploy:tag') 226 | end 227 | 228 | it 'aborts if prospective release_tag does not match release_matcher' do 229 | config.set :release_matcher, /abcd/ 230 | namespace.expects(:abort).with("The release_tag must be matched by the release_matcher regex, 20120101012034 doesn't match (?-mix:abcd)") 231 | namespace.stubs(:git) 232 | namespace.find_and_execute_task('deploy:tag') 233 | end 234 | end 235 | 236 | describe 'deploy:rollback' do 237 | it 'deletes latest tag, resets to previous tag and restarts' do 238 | config.stubs(:latest_tag).returns('release-2') 239 | config.stubs(:latest_tag_from_repository).returns('release-1') 240 | namespace.expects(:git).with('tag -d release-2').in_sequence(commands) 241 | namespace.expects(:git).with('reset --hard release-1').in_sequence(commands) 242 | namespace.expects(:restart).in_sequence(commands) 243 | namespace.find_and_execute_task('deploy:rollback') 244 | end 245 | 246 | it 'aborts if no tag has been deployed' do 247 | config.stubs(:latest_tag).returns(nil) 248 | namespace.rollback.expects(:abort).with('This app is not currently deployed') 249 | namespace.find_and_execute_task('deploy:rollback') 250 | end 251 | end 252 | 253 | describe 'deploy:lock' do 254 | it 'locks deployments with the message DEPLOY_LOCK_MESSAGE if available' do 255 | ENV.stubs("[]").with("DEPLOY_LOCK_MESSAGE").returns("custom-message") 256 | namespace.expects(:claim_lock).with("custom-message") 257 | namespace.find_and_execute_task('deploy:lock') 258 | end 259 | 260 | it 'locks deployments with a default message if no message provided' do 261 | now = Time.now 262 | Time.stubs(:now).returns(now) 263 | namespace.expects(:claim_lock).with("Manually locked at #{Time.now}") 264 | namespace.find_and_execute_task('deploy:lock') 265 | end 266 | end 267 | 268 | describe 'deploy:unlock' do 269 | it 'removes any existing locks' do 270 | namespace.expects(:release_lock) 271 | namespace.find_and_execute_task('deploy:unlock') 272 | end 273 | end 274 | 275 | describe 'deploy:restart' do 276 | it 'does nothing (but can be overidden by other recipes)' do 277 | namespace.expects(:run).never 278 | namespace.expects(:sudo).never 279 | namespace.find_and_execute_task('deploy:restart') 280 | end 281 | end 282 | 283 | describe 'deploy:destroy' do 284 | it 'removes all files from the deployment folder' do 285 | config.set :deploy_to, 'path/to/deploy/app' 286 | config.expects(:sudo).with('rm -rf path/to/deploy/app') 287 | config.find_and_execute_task('deploy:destroy') 288 | end 289 | end 290 | end 291 | end 292 | --------------------------------------------------------------------------------