69 changes: 34 additions & 35 deletions features/extended-config.feature
@@ -1,81 +1,80 @@
Feature: Extended config

Scenario: Config information can be pulled from a separate git repository
Background:
Given a directory named "test-config"
And I cd to "test-config"
And I run `git init .`
And a file named "config.yml" with:
"""
production:
TEST_REMOTE: 'hello_production'
staging:
TEST_REMOTE: 'goodbye_staging'
"""
And I run `git add .`
And I run `git commit -m 'Initial commit'`
And I cd to ".."
Given I run `rails new heroku_san_test -O`
And I cd to "heroku_san_test"
And I overwrite "Gemfile" with:
"""
source :rubygems
gem 'rails'
gem 'heroku_san', :path => '../../../.'
"""

Scenario: Config information can be pulled from a separate git repository
Given a file named "config/heroku.yml" with:
"""
config_repo: 'file:///<%= File.join(File.expand_path(File.dirname(__FILE__)), '..', '..', '..', 'features', 'data', 'test-config') %>'
config_repo: 'file:///<%= File.join(File.expand_path(File.dirname(__FILE__)), '..', 'test-config') %>'
production:
app: awesomeapp
staging:
app: awesomeapp-staging
demo:
app: awesomeapp-demo
"""
When I run `rake --trace all heroku:config:list:local`

When I run `rake all heroku:config:list:local`

Then the output should contain "production TEST_REMOTE: 'hello_world'"
And the output should contain "staging TEST_REMOTE: 'goodbye_world'"
Then the output should contain "TEST_REMOTE: hello_production"
And the output should contain "TEST_REMOTE: goodbye_staging"

Scenario: Config information can be listed
Given I run `rails new heroku_san_test -O`
And I cd to "heroku_san_test"
And I overwrite "Gemfile" with:
"""
source :rubygems
gem 'heroku_san', :path => '../../../.'
"""
Given a file named "config/heroku.yml" with:
"""
production:
app: awesomeapp
config:
TEST_LOCAL: 'hello_world'
TEST_LOCAL: 'hello_production'
staging:
app: awesomeapp-staging
config:
TEST_LOCAL: 'goodbye_world'
TEST_LOCAL: 'goodbye_staging'
demo:
app: awesomeapp-demo
"""
When I run `rake --trace all heroku:config:list:local`

When I run `rake all heroku:config:list:local`

Then the output should contain "production TEST_LOCAL: 'hello_world'"
And the output should contain "staging TEST_LOCAL: 'goodbye_world'"
Then the output should contain "TEST_LOCAL: hello_production"
And the output should contain "TEST_LOCAL: goodbye_staging"

Scenario: Config information can be merged between local and remote
Given I run `rails new heroku_san_test -O`
And I cd to "heroku_san_test"
And I overwrite "Gemfile" with:
"""
source :rubygems
gem 'heroku_san', :path => '../../../.'
"""
Given a file named "config/heroku.yml" with:
"""
config_repo: 'file:///<%= File.join(File.expand_path(File.dirname(__FILE__)), '..', '..', '..', 'features', 'data', 'test-config') %>'
config_repo: 'file:///<%= File.join(File.expand_path(File.dirname(__FILE__)), '..', 'test-config') %>'
production:
app: awesomeapp
config:
TEST_LOCAL: 'hello_world'
TEST_LOCAL: 'hello_production'
staging:
app: awesomeapp-staging
config:
TEST_LOCAL: 'goodbye_world'
TEST_LOCAL: 'goodbye_staging'
TEST_REMOTE: 'overridden_by_remote'
"""
When I run `rake --trace all heroku:config:list:local`

When I run `rake all heroku:config:list:local`

Then the output should contain "production TEST_LOCAL: 'hello_world'"
And the output should contain "production TEST_REMOTE: 'hello_world'"
And the output should contain "staging TEST_LOCAL: 'goodbye_world'"
And the output should contain "staging TEST_REMOTE: 'goodbye_world'"
Then the output should contain "TEST_LOCAL: hello_production"
And the output should contain "TEST_REMOTE: hello_production"
And the output should contain "TEST_LOCAL: goodbye_staging"
And the output should contain "TEST_REMOTE: goodbye_staging"
30 changes: 30 additions & 0 deletions features/remote.feature
@@ -0,0 +1,30 @@
@announce-cmd @slow_process
Feature: heroku_san can control a project on Heroku
WARNING: This WILL create apps on Heroku!
You must login with the heroku cli before starting
this test; otherwise it will probably hang the first
time it tries to do anything with Heroku itself.

Scenario: Installing on a project
Given I have a new Rails project
When I am in the project directory
And I add heroku_san to the Gemfile
And I run bundle install
Then rake reports that the heroku: tasks are available

Scenario: Manipulates the project on Heroku
Given I have a new Rails project
When I am in the project directory
And I add heroku_san to the Gemfile
And I run bundle install
And I create a new config/heroku.yml file
And I create my project on Heroku
And I list the remote configuration
And I curl the app home page
And I configure my project
And I turn maintenance on
And I deploy my project
And I turn maintenance off
And I restart my project
And I list all apps on Heroku
Then heroku_san is green
123 changes: 123 additions & 0 deletions features/step_definitions/remote_steps.rb
@@ -0,0 +1,123 @@
World(Aruba::Api)

Given /^I have a new Rails project$/ do
# template = File.join(File.expand_path(File.dirname(__FILE__)), '..', '..', 'features', 'data', 'template.rb')
cmd = "rails new heroku_san_test --quiet --force --database=postgresql --skip-bundle --skip-javascript --skip-test-unit --skip-sprockets" #" --template #{template}"
run_simple unescape(cmd)
end

When /^I am in the project directory$/ do
cd '/heroku_san_test'
end

When /^I add heroku_san to the Gemfile$/ do
append_to_file 'Gemfile', <<EOT
group :development, :test do
gem 'heroku_san', :path => '../../../.'
end
EOT
end

When /^I run bundle install$/ do
use_clean_gemset 'heroku_san_test'
run_simple 'bundle install --quiet'
write_file '.rvmrc', "rvm use default@heroku_san_test\n"
end

Then /^rake reports that the heroku: tasks are available$/ do
run_simple 'rake -T heroku:'
assert_partial_output 'rake heroku:apps', all_output
end

When /^I create a new config\/heroku\.yml file$/ do
run_simple 'rake heroku:create_config'
assert_matching_output %q{Copied example config to ".*.config.heroku.yml"}, all_output
assert_matching_output %q{Please edit ".*.config.heroku.yml" with your application's settings.}, all_output
overwrite_file 'config/heroku.yml', <<EOT
---
test_app:
EOT
end

When /^I create my project on Heroku$/ do
cmd = 'rake test_app heroku:create'
run_simple unescape(cmd)
assert_matching_output %q{test_app: Created ([\w-]+)}, all_output
output = stdout_from cmd
@app = output.match(/test_app: Created ([\w-]+)/)[1]
overwrite_file 'config/heroku.yml', <<EOT
---
test_app:
app: #{@app}
EOT
end

When /^I list the remote configuration$/ do
cmd = 'rake test_app heroku:config:list'
run_simple unescape(cmd)
assert_partial_output "APP_NAME: #{@app}", all_output
assert_partial_output "URL: #{@app}.heroku.com", all_output
output = stdout_from cmd
@url = output.match(/\bURL:\s+(.*.heroku.com)\b/)[1]
@curl = unescape("curl --silent http://#{@url}")
end

When /^I curl the app home page$/ do
run_simple @curl
output = stdout_from @curl
assert_partial_output '<h1><strong>Heroku | Welcome to your new app!</strong></h1>', output
end

When /^I configure my project$/ do
overwrite_file 'config/heroku.yml', <<EOT
---
test_app:
app: #{@app}
config:
DROIDS: marvin
EOT
cmd = 'rake test_app heroku:config'
run_simple unescape(cmd)
assert_partial_output 'DROIDS: marvin', all_output
end

When /^I turn maintenance on$/ do
run_simple 'rake test_app heroku:maintenance_on'
assert_partial_output 'test_app: Maintenance mode enabled.', all_output
run_simple @curl
output = stdout_from @curl
assert_partial_output '<title>Offline for Maintenance</title>', all_output
end

When /^I turn maintenance off$/ do
run_simple 'rake test_app heroku:maintenance_off'
assert_partial_output 'test_app: Maintenance mode disabled.', all_output
run_simple @curl + "/droids"
assert_partial_output %Q{<code>marvin</code>}, all_output
end

When /^I restart my project$/ do
run_simple 'rake test_app heroku:restart'
assert_partial_output 'test_app: Restarted.', all_output
end

When /^I deploy my project$/ do
run_simple 'git init .'
run_simple 'rails generate scaffold droids'
append_to_file 'app/views/droids/index.html.erb', %Q{\n<div><code><%= ENV['DROIDS'] -%></code></div>\n}
run_simple 'git add .'
run_simple 'git commit -m "Initial commit"'
run_simple 'rake test_app deploy'
assert_partial_output "http://#{@app}.heroku.com deployed to Heroku", all_output
end

When /^I list all apps on Heroku$/ do
run_simple 'rake heroku:apps'
assert_partial_output "test_app is shorthand for the Heroku app #{@app} located at:", all_output
assert_partial_output "git@heroku.com:#{@app}.git", all_output
assert_matching_output '@ \w{40} master', all_output
end

Then /^heroku_san is green$/ do
run_simple "heroku apps:destroy #{@app} --confirm #{@app}"
end
9 changes: 9 additions & 0 deletions features/support/env.rb
Expand Up @@ -6,3 +6,12 @@
unless File.readable? File.join(File.dirname(__FILE__), '..', 'data', 'test-config', 'config.yml')
`git submodule init && git submodule update`
end

Before do
@aruba_timeout_seconds = 15
end

Before('@slow_process') do
@aruba_timeout_seconds = 180
# @aruba_io_wait_seconds = 15
end
16 changes: 9 additions & 7 deletions heroku_san.gemspec
@@ -1,16 +1,16 @@
# -*- encoding: utf-8 -*-
require File.join(File.dirname(__FILE__), 'lib/heroku_san/version')

Gem::Specification.new do |s|
s.name = %q{heroku_san}

s.version = "1.3.0"
s.date = %q{2011-11-07}
s.version = HerokuSan::VERSION

s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
s.authors = ["Elijah Miller", "Glenn Roberts", "Ryan Ahearn"]

s.authors = ["Elijah Miller", "Glenn Roberts", "Ryan Ahearn", "Ken Mayer"]
s.description = %q{Manage multiple Heroku instances/apps for a single Rails app using Rake}
s.email = %q{elijah.miller@gmail.com}
s.homepage = %q{http://github.com/glennr/heroku_san}
s.homepage = %q{http://github.com/fastestforward/heroku_san}
s.files = `git ls-files`.split("\n")
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
s.require_paths = ["lib"]
Expand All @@ -22,11 +22,13 @@ Gem::Specification.new do |s|
s.specification_version = 3

if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
s.add_runtime_dependency(%q<rails>, ['>= 2'])
s.add_development_dependency(%q<rails>, ['>= 2'])
s.add_runtime_dependency(%q<heroku>, ['>= 2'])
s.add_development_dependency(%q<rails>, ['>= 3'])
s.add_runtime_dependency(%q<rake>)
s.add_development_dependency(%q<aruba>)
s.add_development_dependency(%q<cucumber>)
s.add_development_dependency(%q<rake>)
s.add_development_dependency(%q<bundler>, ['~> 1.1 '])
else
s.add_dependency(%q<rails>, ['>= 2'])
s.add_dependency(%q<heroku>, ['>= 2'])
Expand Down
47 changes: 47 additions & 0 deletions lib/git.rb
@@ -0,0 +1,47 @@
require 'rake'
require 'rake/dsl_definition'

module Git
include Rake::DSL

def git_clone(repos, dir)
sh "git clone #{repos} #{dir}"
end

def git_active_branch
%x{git branch}.split("\n").select { |b| b =~ /^\*/ }.first.split(" ").last.strip
end

def git_push(commit, repo, options = [])
commit ||= "HEAD"
options ||= []
begin
sh "git update-ref refs/heroku_san/deploy #{commit}"
sh "git push #{repo} #{options.join(' ')} refs/heroku_san/deploy:refs/heads/master"
ensure
sh "git update-ref -d refs/heroku_san/deploy"
end
end

def git_parsed_tag(tag)
git_rev_parse(git_tag(tag))
end

def git_rev_parse(ref)
return nil if ref.nil?
%x{git rev-parse #{ref}}.split("\n").first
end

def git_tag(glob)
return nil if glob.nil?
%x{git tag -l '#{glob}'}.split("\n").last
end

def git_revision(repo)
%x{git ls-remote --heads #{repo} master}.split.first
end

def git_named_rev(ref)
%x{git name-rev #{ref}}.chomp
end
end
11 changes: 10 additions & 1 deletion lib/heroku_san.rb
@@ -1 +1,10 @@
require 'heroku_san/railtie.rb' if defined?(Rails) && Rails::VERSION::MAJOR == 3
require 'railtie' if defined?(Rails) && Rails::VERSION::MAJOR == 3
require 'git'
require 'heroku_san/stage'
require 'heroku_san/project'

module HerokuSan
class NoApps < StandardError; end
class MissingApp < StandardError; end
class Deprecated < StandardError; end
end
112 changes: 112 additions & 0 deletions lib/heroku_san/project.rb
@@ -0,0 +1,112 @@
module HerokuSan
class Project
attr_reader :config_file

include Git

def initialize(config_file)
@apps = []
@config_file = config_file
@app_settings = {}
config = parse(@config_file)
config.each do |stage, settings|
@app_settings[stage] = HerokuSan::Stage.new(stage, settings)
end
end

def create_config
template = File.expand_path(File.join(File.dirname(__FILE__), '../templates', 'heroku.example.yml'))
if File.exists?(@config_file)
false
else
FileUtils.cp(template, @config_file)
true
end
end

def all
@app_settings.keys
end

def [](stage)
@app_settings[stage]
end

def <<(*app)
app.flatten.each do |a|
@apps << a if all.include?(a)
end
self
end

def apps
if !@apps.empty?
@apps
else
case all.size
when 1
$stdout.puts "Defaulting to #{all.first.inspect} since only one app is defined"
all
else
active_branch = self.git_active_branch
all.select do |app|
app == active_branch and ($stdout.puts("Defaulting to '#{app}' as it matches the current branch") || true)
end
end
end
end

def each_app
raise NoApps if apps.empty?
apps.each do |stage|
yield(self[stage])
end
end

private

def parse_yaml(config_file)
if File.exists?(config_file)
if defined?(ERB)
YAML.load(ERB.new(File.read(config_file)).result)
else
YAML.load_file(config_file)
end
else
{}
end
end

def parse(config_file)
app_settings = parse_yaml(config_file)

# support heroku_san format
if app_settings.has_key? 'apps'
app_settings = app_settings['apps']
app_settings.each_pair do |stage, app_name|
app_settings[stage] = {'app' => app_name}
end
end

# load external config
if (config_repo = app_settings.delete('config_repo'))
require 'tmpdir'
tmp_config_dir = Dir.mktmpdir
tmp_config_file = File.join tmp_config_dir, 'config.yml'
git_clone(config_repo, tmp_config_dir)
extra_config = parse_yaml(tmp_config_file)
else
extra_config = {}
end

# make sure each app has a 'config' section & merge w/extra
app_settings.keys.each do |name|
app_settings[name] ||= {}
app_settings[name]['config'] ||= {}
app_settings[name]['config'].merge!(extra_config[name]) if extra_config[name]
end

app_settings
end
end
end
117 changes: 117 additions & 0 deletions lib/heroku_san/stage.rb
@@ -0,0 +1,117 @@
require 'heroku'
require 'json'

module HerokuSan
class Stage
attr_reader :name
include Git

def initialize(stage, options = {})
@name = stage
@options = options
end

def heroku
Heroku::Auth.client
end

def app
@options['app'] or raise MissingApp, "#{name}: is missing the app: configuration value. I don't know what to access on Heroku."
end

def repo
@options['repo'] ||= "git@heroku.com:#{app}.git"
end

def stack
@options['stack'] ||= heroku.list_stacks(app).detect{|stack| stack['current']}['name']
end

def tag
@options['tag']
end

def config
@options['config'] ||= {}
end

def run(command, args = nil)
if stack =~ /cedar/
sh_heroku "run #{command} #{args}"
else
sh_heroku "run:#{command} #{args}"
end
end

def deploy(sha = nil, force = false)
sha ||= git_parsed_tag(tag)
git_push(sha, repo, force ? %w[--force] : [])
end

def migrate
rake('db:migrate')
restart
end

def rake(*args)
run 'rake', args.join(' ')
# heroku.rake app, args.join(' ')
end

def maintenance(action = nil)
if block_given?
heroku.maintenance(app, :on)
begin
yield
ensure
heroku.maintenance(app, :off)
end
else
raise ArgumentError, "Action #{action.inspect} must be one of (:on, :off)", caller if ![:on, :off].include?(action)
heroku.maintenance(app, action)
end
end

def create # DEPREC?
if @options['stack']
heroku.create(@options['app'], {:stack => @options['stack']})
else
heroku.create(@options['app'])
end
end

def sharing_add(email) # DEPREC?
sh_heroku "sharing:add #{email.chomp}"
end

def sharing_remove(email) # DEPREC?
sh_heroku "sharing:remove #{email.chomp}"
end

def long_config
heroku.config_vars(app)
end

def push_config(options = nil)
JSON.parse(heroku.add_config_vars(app, options || config))
end

def restart
heroku.ps_restart(app)
end

def logs(tail = false)
sh_heroku 'logs' + (tail ? ' --tail' : '')
end

def revision
git_named_rev(git_revision(repo))
end

private

def sh_heroku(command)
sh "heroku #{command} --app #{app}"
end
end
end
419 changes: 0 additions & 419 deletions lib/heroku_san/tasks.rb

This file was deleted.

3 changes: 3 additions & 0 deletions lib/heroku_san/version.rb
@@ -0,0 +1,3 @@
module HerokuSan
VERSION = "2.1.1"
end
2 changes: 1 addition & 1 deletion lib/heroku_san/railtie.rb → lib/railtie.rb
Expand Up @@ -4,7 +4,7 @@
module HerokuSan
class Railtie < Rails::Railtie
rake_tasks do
load 'heroku_san/tasks.rb'
load 'tasks.rb'
end
end
end
312 changes: 312 additions & 0 deletions lib/tasks.rb
@@ -0,0 +1,312 @@
require 'git'
include Git

@heroku_san = HerokuSan::Project.new(Rails.root.join('config', 'heroku.yml'))

@heroku_san.all.each do |stage|
desc "Select #{stage} Heroku app for later commands"
task "heroku:stage:#{stage}" do
@heroku_san << stage
end
task stage => "heroku:stage:#{stage}"
end

namespace :heroku do
desc 'Select all Heroku apps for later command'
task 'stage:all' do
@heroku_san << @heroku_san.all
end

desc "Creates the Heroku app"
task :create do
each_heroku_app do |stage|
puts "#{stage.name}: Created #{stage.create}"
end
end

#desc "Generate the Heroku gems manifest from gem dependencies"
task :gems => 'gems:base' do
raise HerokuSan::Deprecated
end

desc 'Add git remotes for all apps in this project'
task :remotes do
each_heroku_app do |stage|
sh "git remote add #{stage.name} #{stage.repo}"
end
end

desc 'Adds a collaborator (asks for email)'
task :share do
print "Email address of collaborator to add: "
$stdout.flush
email = $stdin.gets
each_heroku_app do |stage|
stage.sharing_add email
end
end

desc 'Removes a collaborator (asks for email)'
task :unshare do
print "Email address of collaborator to remove: "
$stdout.flush
email = $stdin.gets
each_heroku_app do |stage|
stage.sharing_remove email
end
end

desc 'Lists configured apps'
task :apps => :all do
each_heroku_app do |stage|
rev = stage.revision
puts "#{stage.name} is shorthand for the Heroku app #{stage.app} located at:"
puts " #{stage.repo}"
puts " @ #{rev.blank? ? 'not deployed' : rev}"
puts
end
end

namespace :apps do
desc 'Lists configured apps without hitting heroku'
task :local => :all do
each_heroku_app do |stage|
puts "#{stage.name} is shorthand for the Heroku app #{stage.app} located at:"
puts " #{stage.repo}"
puts " the #{stage.name} TAG is '#{stage.tag}'" if stage.tag
puts
end
end
end

desc 'Add config:vars to each application.'
task :config do
each_heroku_app do |stage|
puts y(stage.push_config)
end
end

desc 'Creates an example configuration file'
task :create_config do
filename = %Q{#{@heroku_san.config_file.to_s}}
if @heroku_san.create_config
puts "Copied example config to #{filename.inspect}"
if ENV['EDITOR'] && ENV['EDITOR'] != ''
sh "#{ENV['EDITOR']} #{filename}"
else
puts "Please edit #{filename.inspect} with your application's settings."
end
else
puts "#{filename.inspect} already exists"
end
end

namespace :config do
desc 'Add proper RACK_ENV to each application'
task :rack_env => :all do
each_heroku_app do |stage|
command = "heroku config --app #{stage.app}"
puts command
config = Hash[`#{command}`.scan(/^(.+?)\s*=>\s*(.+)$/)]
if config['RACK_ENV'] != stage.name
puts stage.push_config RACK_ENV: stage.name
end
end
end

desc "Lists config variables as set on Heroku"
task :list do
each_heroku_app do |stage|
puts "#{stage.name}:"
puts y(stage.long_config)
end
end

namespace :list do
desc "Lists local config variables without setting them"
task :local do
each_heroku_app do |stage|
puts "#{stage.name}:"
puts y(stage.config)
end
end
end
end

desc 'Runs a rake task remotely'
task :rake, [:task] do |t, args|
each_heroku_app do |stage|
puts stage.rake args.task
end
end

desc "Pushes the given commit (default: HEAD)"
task :push, :commit do |t, args|
each_heroku_app do |stage|
stage.deploy(args[:commit])
end
end

namespace :push do
desc "Force-pushes the given commit (default: HEAD)"
task :force, :commit do |t, args|
each_heroku_app do |stage|
stage.deploy(args[:commit], :force)
end
end
end

desc "Enable maintenance mode"
task :maintenance do
each_heroku_app do |stage|
stage.maintenance :on
puts "#{stage.name}: Maintenance mode enabled."
end
end

desc "Enable maintenance mode"
task :maintenance_on do
each_heroku_app do |stage|
stage.maintenance :on
puts "#{stage.name}: Maintenance mode enabled."
end
end

desc "Disable maintenance mode"
task :maintenance_off do
each_heroku_app do |stage|
stage.maintenance :off
puts "#{stage.name}: Maintenance mode disabled."
end
end

desc "Pushes the given commit, migrates and restarts (default: HEAD)"
task :deploy, [:commit] => [:before_deploy] do |t, args|
each_heroku_app do |stage|
stage.deploy(args[:commit])
stage.migrate
end
Rake::Task[:after_deploy].execute
end

namespace :deploy do
desc "Force-pushes the given commit, migrates and restarts (default: HEAD)"
task :force, [:commit] => [:before_deploy] do |t, args|
each_heroku_app do |stage|
stage.deploy(args[:commit], :force)
stage.migrate
end
Rake::Task[:after_deploy].execute
end

desc "Callback before deploys"
task :before do
end

desc "Callback after deploys"
task :after do
end

end

task :force_deploy do
raise HerokuSan::Deprecated
end

#desc "Captures a bundle on Heroku"
task :capture do
raise HerokuSan::Deprecated
end

desc "Opens a remote console"
task :console do
each_heroku_app do |stage|
stage.run 'console'
end
end

desc "Restarts remote servers"
task :restart do
each_heroku_app do |stage|
stage.restart
puts "#{stage.name}: Restarted."
end
end

namespace :logs do
task :default do
each_heroku_app do |stage|
stage.logs
end
end

desc "Tail the Heroku logs (requires logging:expanded)"
task :tail do
each_heroku_app do |stage|
stage.logs(:tail)
end
end
end

desc "Shows the Heroku logs"
task :logs => 'logs:default'

namespace :db do
desc "Migrates and restarts remote servers"
task :migrate do
each_heroku_app do |stage|
stage.migrate
end
end

desc "Pull database from stage to local dev database"
task :pull do
each_heroku_app do |stage|
sh "heroku pgdumps:capture --app #{stage.app}"
dump = `heroku pgdumps --app #{stage.app}`.split("\n").last.split(" ").first
sh "mkdir -p #{Rails.root}/db/dumps"
file = "#{Rails.root}/db/dumps/#{dump}.sql.gz"
url = `heroku pgdumps:url --app #{stage.app} #{dump}`.chomp
sh "wget", url, "-O", file
sh "rake db:drop db:create"
sh "gunzip -c #{file} | #{Rails.root}/script/dbconsole"
sh "rake jobs:clear"
end
end
end

desc "Run a bash shell on Heroku"
task :shell do
each_heroku_app do |stage|
stage.run 'bash'
end
end
end

task :all => 'heroku:stage:all'
task :deploy => 'heroku:deploy'
task 'deploy:force' => 'heroku:deploy:force'
task :before_deploy => 'heroku:deploy:before'
task :after_deploy => 'heroku:deploy:after'
task :console => 'heroku:console'
task :restart => 'heroku:restart'
task :migrate => 'heroku:db:migrate'
task :logs => 'heroku:logs:default'
task 'logs:tail' => 'heroku:logs:tail'
task 'heroku:rack_env' => 'heroku:config:rack_env'
task :shell => 'heroku:shell'

def each_heroku_app(&block)
@heroku_san.each_app(&block)
puts
rescue HerokuSan::NoApps => e
puts "You must first specify at least one Heroku app:
rake <app> [<app>] <command>
rake production restart
rake demo staging deploy"

puts "\nYou can use also command all Heroku apps for this project:
rake all heroku:share"

exit(1)
end
2 changes: 1 addition & 1 deletion lib/tasks/heroku.rake
@@ -1 +1 @@
require File.expand_path(File.join(File.dirname(__FILE__), '..', 'heroku_san', 'tasks'))
require File.expand_path(File.join(File.dirname(__FILE__), '..', 'tasks'))
5 changes: 4 additions & 1 deletion lib/templates/heroku.example.yml
@@ -1,15 +1,18 @@
#
# Format:
#
# <heroku_san shorthand name>:
# <stage name>:
# app: <Heroku app name>
# stack: <Heroku stack, optional>
# tag: <git tag pattern, optional>
# repo: <git repository, optional>
# config:
# - <Heroku config:var name>: <Heroku config:var value>
#
production:
app: awesomeapp
stack: bamboo-ree-1.8.7
tag: production/*
config:
BUNDLE_WITHOUT: "development:test"
GOOGLE_ANALYTICS: "UA-12345678-1"
Expand Down
26 changes: 26 additions & 0 deletions spec/fixtures/example.yml
@@ -0,0 +1,26 @@
#
# Format:
#
# <heroku_san shorthand name>:
# app: <Heroku app name>
# tag: <git tag pattern>
# config:
# - <Heroku config:var name>: <Heroku config:var value>
#
production:
app: awesomeapp
tag: production/*
config:
BUNDLE_WITHOUT: "development:test"
GOOGLE_ANALYTICS: "UA-12345678-1"

staging:
app: awesomeapp-staging
stack: bamboo-ree-1.8.7
config: &default
BUNDLE_WITHOUT: "development:test"

demo:
app: awesomeapp-demo
stack: cedar
config: *default
7 changes: 7 additions & 0 deletions spec/fixtures/extended_config.yml
@@ -0,0 +1,7 @@
config_repo: 'file:///<%= File.join(File.expand_path(File.dirname(__FILE__)), '..', '..', '..', 'features', 'data', 'test-config') %>'
production:
app: awesomeapp
staging:
app: awesomeapp-staging
demo:
app: awesomeapp-demo
10 changes: 10 additions & 0 deletions spec/fixtures/old_format.yml
@@ -0,0 +1,10 @@
#
# Format:
#
# apps:
# shorthand: <Heroku app name>
#
apps:
production: awesomeapp
staging: awesomeapp-staging
demo: awesomeapp-demo
6 changes: 6 additions & 0 deletions spec/fixtures/single_app.yml
@@ -0,0 +1,6 @@
production:
app: awesomeapp
tag: production/*
config:
BUNDLE_WITHOUT: "development:test"
GOOGLE_ANALYTICS: "UA-12345678-1"
74 changes: 74 additions & 0 deletions spec/git_spec.rb
@@ -0,0 +1,74 @@
require 'spec_helper'
require 'git'

class GitTest; include Git; end

describe GitTest do
describe "#git_push" do
it "pushes to heroku" do
subject.should_receive(:sh).with("git update-ref refs/heroku_san/deploy HEAD")
subject.should_receive(:sh).with("git push git@heroku.com:awesomeapp.git refs/heroku_san/deploy:refs/heads/master")
subject.should_receive(:sh).with("git update-ref -d refs/heroku_san/deploy")
subject.git_push(nil, 'git@heroku.com:awesomeapp.git')
end

it "pushes a specific commit to heroku" do
subject.should_receive(:sh).with("git update-ref refs/heroku_san/deploy kommit")
subject.should_receive(:sh).with("git push git@heroku.com:awesomeapp.git refs/heroku_san/deploy:refs/heads/master")
subject.should_receive(:sh).with("git update-ref -d refs/heroku_san/deploy")
subject.git_push('kommit', 'git@heroku.com:awesomeapp.git')
end

it "includes options, too" do
subject.should_receive(:sh).with("git update-ref refs/heroku_san/deploy HEAD")
subject.should_receive(:sh).with("git push git@heroku.com:awesomeapp.git --force -v refs/heroku_san/deploy:refs/heads/master")
subject.should_receive(:sh).with("git update-ref -d refs/heroku_san/deploy")
subject.git_push(nil, 'git@heroku.com:awesomeapp.git', %w[--force -v])
end
end

describe "#git_tag" do
it "returns the latest tag that matches the pattern" do
subject.should_receive("`").with("git tag -l 'pattern*'") { "x\n\y\n\z\n" }
subject.git_tag('pattern*').should == "z"
end
it "returns nil if no tags match the pattern" do
subject.should_receive("`").with("git tag -l 'pattern*'") { "\n" }
subject.git_tag('pattern*').should == nil
end
it "returns nil for a nil tag" do
subject.should_not_receive("`").with("git tag -l ''") { "\n" }
subject.git_tag(nil).should == nil
end
end

describe "#git_rev_parse" do
it "returns the rev based on the tag" do
subject.should_receive("`").with("git rev-parse prod/1234567890") { "sha\n" }
subject.git_rev_parse('prod/1234567890').should == "sha"
end
it "returns nil for a blank tag" do
subject.should_not_receive("`").with("git rev-parse ") { "\n" }
subject.git_rev_parse(nil).should == nil
end
end

describe "#git_revision" do
it "returns the current revision of the repository (on Heroku)" do
subject.should_receive("`").with("git ls-remote --heads staging master") { "sha\n" }
subject.git_revision('staging').should == 'sha'
end

it "returns nil if there is no revision (i.e. not deployed yet)" do
subject.should_receive("`").with("git ls-remote --heads staging master") { "\n" }
subject.git_revision('staging').should == nil
end
end

describe "#git_named_rev" do
it "returns symbolic names for given rev" do
subject.should_receive("`").with("git name-rev sha") {"sha production/123456\n"}
subject.git_named_rev('sha').should == 'sha production/123456'
end
end
end
106 changes: 106 additions & 0 deletions spec/heroku_san/project_spec.rb
@@ -0,0 +1,106 @@
require 'spec_helper'
require 'tmpdir'

describe HerokuSan::Project do
specify ".new with a missing config file" do
heroku_san = HerokuSan::Project.new("/u/should/never/get/here")
heroku_san.all.should == []
end

context "using the example config file" do
let(:heroku_config_file) { File.join(SPEC_ROOT, "fixtures", "example.yml") }
let(:template_config_file) {
path = File.join(SPEC_ROOT, "..", "lib/templates", "heroku.example.yml")
(File.respond_to? :realpath) ? File.realpath(path) : path
}
let(:heroku_san) { HerokuSan::Project.new(heroku_config_file) }
subject { heroku_san }

its(:all) { should =~ %w[production staging demo] }

context "using the heroku_san format" do
let(:heroku_san) { HerokuSan::Project.new(File.join(SPEC_ROOT, "fixtures", "old_format.yml")) }

it "returns a list of apps" do
heroku_san.all.should =~ %w[production staging demo]
end
end

describe "Adding an app to the deploy list" do
it "appends known shorthands to apps" do
heroku_san.apps.should == []
heroku_san << 'production'
heroku_san.apps.should == %w[production]
heroku_san << 'staging'
heroku_san.apps.should == %w[production staging]
heroku_san << 'unknown'
heroku_san.apps.should == %w[production staging]
end

it "appends .all (or any array)" do
heroku_san << heroku_san.all
heroku_san.apps.should == heroku_san.all
end
end

describe "#apps extra default behaviors" do
specify "on a git branch that matches an app name" do
heroku_san.should_receive(:git_active_branch) { "staging" }
$stdout.should_receive(:puts).with("Defaulting to 'staging' as it matches the current branch")
heroku_san.apps.should == %w[staging]
end

specify "on a git branch that doesn't matches an app name" do
heroku_san.should_receive(:git_active_branch) { "master" }
heroku_san.apps.should == %w[]
end

context "but only a single configured app" do
let(:heroku_san) { HerokuSan::Project.new(File.join(SPEC_ROOT, "fixtures", "single_app.yml")) }
it "returns the app" do
$stdout.should_receive(:puts).with('Defaulting to "production" since only one app is defined')
heroku_san.apps.should == %w[production]
end
end
end

describe "#each_app" do
it "raises an error is no apps were specified" do
expect { heroku_san.each_app do true; end }.to raise_error HerokuSan::NoApps
end

it "yields to a block with args" do
heroku_san << 'production'
block = double('block')
block.should_receive(:action).with(heroku_san['production'])
heroku_san.each_app do |stage|
block.action(stage)
end
end
end

describe "#[]" do
it "returns a config section" do
heroku_san.all.each do |app|
heroku_san[app].should be_a HerokuSan::Stage
end
end
end

describe "#create_config" do
it "creates a new file using the example file" do
Dir.mktmpdir do |dir|
tmp_config_file = File.join dir, 'config.yml'
heroku_san = HerokuSan::Project.new(tmp_config_file)
FileUtils.should_receive(:cp).with(File.expand_path(template_config_file), tmp_config_file)
heroku_san.create_config.should be_true
end
end

it "does not overwrite an existing file" do
FileUtils.should_not_receive(:cp)
heroku_san.create_config.should be_false
end
end
end
end
211 changes: 211 additions & 0 deletions spec/heroku_san/stage_spec.rb
@@ -0,0 +1,211 @@
require 'spec_helper'
require 'heroku/client'

describe HerokuSan::Stage do
include Git
subject { HerokuSan::Stage.new('production', {"app" => "awesomeapp", "stack" => "bamboo-ree-1.8.7"})}

before do
@heroku_client = mock(Heroku::Client)
Heroku::Auth.stub(:client).and_return(@heroku_client)
end

context "initializes" do
subject { HerokuSan::Stage.new('production',
{"stack" => "cedar",
"app" => "awesomeapp-demo",
"tag" => "demo/*",
"config"=> {"BUNDLE_WITHOUT"=>"development:test"}
})}

its(:name) { should == 'production' }
its(:app) { should == 'awesomeapp-demo' }
its(:stack) { should == 'cedar' }
its(:tag) { should == "demo/*" }
its(:config) { should == {"BUNDLE_WITHOUT"=>"development:test"} }
its(:repo) { should == 'git@heroku.com:awesomeapp-demo.git' }
end

describe "#app" do
its(:app) { should == 'awesomeapp'}
context "blank app" do
subject { HerokuSan::Stage.new('production') }
it "should raise an error" do
expect { subject.app }.to raise_error(HerokuSan::MissingApp, /production: is missing the app: configuration value\./)
end
end
end

describe "#stack" do
it "returns the name of the stack from Heroku" do
subject = HerokuSan::Stage.new('production', {"app" => "awesomeapp"})
@heroku_client.should_receive(:list_stacks).with('awesomeapp').
and_return { [{'name' => 'other'}, {'name' => 'the-one', 'current' => true}] }
subject.stack.should == 'the-one'
end

it "returns the stack name from the config if it is set there" do
subject = HerokuSan::Stage.new('production', {"app" => "awesomeapp", "stack" => "cedar"})
subject.stack.should == 'cedar'
end
end

describe "#run" do
it "runs commands using the pre-cedar format" do
subject.should_receive(:sh).with("heroku run:rake foo bar bleh --app awesomeapp")
subject.run 'rake', 'foo bar bleh'
end
it "runs commands using the new cedar format" do
subject = HerokuSan::Stage.new('production', {"app" => "awesomeapp", "stack" => "cedar"})
subject.should_receive(:sh).with("heroku run worker foo bar bleh --app awesomeapp")
subject.run 'worker', 'foo bar bleh'
end
end

describe "#deploy" do
it "deploys to heroku" do
subject.should_receive(:git_push).with(git_parsed_tag(subject.tag), subject.repo, [])
subject.deploy
end

it "deploys with a custom sha" do
subject.should_receive(:git_push).with('deadbeef', subject.repo, [])
subject.deploy('deadbeef')
end

it "deploys with --force" do
subject.should_receive(:git_push).with(git_parsed_tag(subject.tag), subject.repo, %w[--force])
subject.deploy(nil, :force)
end

it "deploys with a custom sha & --force" do
subject.should_receive(:git_push).with('deadbeef', subject.repo, %w[--force])
subject.deploy('deadbeef', :force)
end
end

describe "#migrate" do
it "runs rake db:migrate" do
subject.should_receive(:rake).with('db:migrate').and_return 'output:'
# @heroku_client.should_receive(:rake).with('awesomeapp', 'db:migrate').and_return "output:"
@heroku_client.should_receive(:ps_restart).with('awesomeapp').and_return "restarted"
subject.migrate.should == "restarted"
end
end

describe "#maintenance" do
it ":on" do
@heroku_client.should_receive(:maintenance).with('awesomeapp', :on) {'on'}
subject.maintenance(:on).should == 'on'
end

it ":off" do
@heroku_client.should_receive(:maintenance).with('awesomeapp', :off) {'off'}
subject.maintenance(:off).should == 'off'
end

it "otherwise raises an ArgumentError" do
expect do
subject.maintenance :busy
end.to raise_error ArgumentError, "Action #{:busy.inspect} must be one of (:on, :off)"
end

context "with a block" do
it "wraps it in a maitenance mode" do
reactor = mock("Reactor"); reactor.should_receive(:scram).with(:now).ordered
@heroku_client.should_receive(:maintenance).with('awesomeapp', :on).ordered
@heroku_client.should_receive(:maintenance).with('awesomeapp', :off).ordered
subject.maintenance do reactor.scram(:now) end
end
it "ensures that maintenance mode is turned off" do
@heroku_client.should_receive(:maintenance).with('awesomeapp', :on).ordered
reactor = mock("Reactor"); reactor.should_receive(:scram).with(:now).and_raise(RuntimeError)
@heroku_client.should_receive(:maintenance).with('awesomeapp', :off).ordered
expect {
subject.maintenance do reactor.scram(:now) end
}.to raise_error
end
end
end

describe "#create" do
it "creates an app on heroku" do
@heroku_client.should_receive(:create).with('awesomeapp', {:stack => 'bamboo-ree-1.8.7'})
subject.create
end
it "uses the default stack if none is given" do
subject = HerokuSan::Stage.new('production', {"app" => "awesomeapp"})
@heroku_client.should_receive(:create).with('awesomeapp')
subject.create
end
it "sends a nil app name if none is given (Heroku will generate one)" do
subject = HerokuSan::Stage.new('production', {"app" => nil})
@heroku_client.should_receive(:create).with(nil).and_return('warm-ocean-9218')
subject.create.should == 'warm-ocean-9218'
end
end

describe "#sharing_add" do
it "add collaborators" do
subject.should_receive(:sh).with("heroku sharing:add email@example.com --app awesomeapp")
subject.sharing_add 'email@example.com'
end
end

describe "#sharing_remove" do
it "removes collaborators" do
subject.should_receive(:sh).with("heroku sharing:remove email@example.com --app awesomeapp")
subject.sharing_remove 'email@example.com'
end
end

describe "#long_config" do
it "returns the remote config" do
@heroku_client.should_receive(:config_vars).with('awesomeapp') { {'A' => 'one', 'B' => 'two'} }
subject.long_config.should == { 'A' => 'one', 'B' => 'two' }
end
end

describe "#restart" do
it "restarts an app" do
@heroku_client.should_receive(:ps_restart).with('awesomeapp').and_return "restarted"
subject.restart.should == 'restarted'
end
end

describe "#logs" do
it "returns log files" do
subject.should_receive(:sh).with("heroku logs --app awesomeapp")
subject.logs
end
it "tails log files" do
subject.should_receive(:sh).with("heroku logs --tail --app awesomeapp")
subject.logs(:tail)
end
end

describe "#push_config" do
it "updates the configuration settings on Heroku" do
subject = HerokuSan::Stage.new('test', {"app" => "awesomeapp", "config" => {FOO: 'bar', DOG: 'emu'}})
@heroku_client.should_receive(:add_config_vars).with('awesomeapp', {:FOO => 'bar', :DOG => 'emu'}).and_return("{}")
subject.push_config
end
it "pushes the options hash" do
@heroku_client.should_receive(:add_config_vars).with('awesomeapp', {:RACK_ENV => 'magic'}).and_return("{}")
subject.push_config(RACK_ENV: 'magic')
end
end

describe "#revision" do
it "returns the named remote revision for the stage" do
subject.should_receive(:git_revision).with(subject.repo) {"sha"}
subject.should_receive(:git_named_rev).with('sha') {"sha production/123456"}
subject.revision.should == 'sha production/123456'
end
it "returns nil if the stage has never been deployed" do
subject.should_receive(:git_revision).with(subject.repo) {nil}
subject.should_receive(:git_named_rev).with(nil) {''}
subject.revision.should == ''
end
end
end
21 changes: 21 additions & 0 deletions spec/spec_helper.rb
@@ -0,0 +1,21 @@
require 'bundler'
Bundler.setup

SPEC_ROOT = File.dirname(__FILE__)

# Requires supporting ruby files with custom matchers and macros, etc,
# in spec/support/ and its subdirectories.
Dir[File.join(SPEC_ROOT, "support/**/*.rb")].each {|f| require f}

RSpec.configure do |config|
# == Mock Framework
#
# If you prefer to use mocha, flexmock or RR, uncomment the appropriate line:
#
# config.mock_with :mocha
# config.mock_with :flexmock
# config.mock_with :rr
config.mock_with :rspec
end

require File.join(SPEC_ROOT, '/../lib/heroku_san')