robot-army-git-deploy 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Copyright 2007 Wesabe, Inc.
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
data/README.markdown ADDED
@@ -0,0 +1,4 @@
1
+ Robot Army Git Deploy
2
+ =====================
3
+
4
+ Deployment for Robot Army for projects using a git repository.
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ begin
2
+ require 'rubygems'
3
+ require 'thor'
4
+
5
+ $stderr.puts "Robot Army uses thor, not rake. Here are the available tasks:"
6
+ exec "thor -T"
7
+ rescue LoadError
8
+ $stderr.puts "Robot Army uses thor, not rake. Please install thor."
9
+ end
@@ -0,0 +1,12 @@
1
+ $:.unshift(File.dirname(__FILE__))
2
+
3
+ # external libraries
4
+ require 'rubygems'
5
+ require 'grit'
6
+ require 'robot-army'
7
+ require 'fileutils'
8
+ require 'highline'
9
+
10
+ # internal files
11
+ require 'robot-army-git-deploy/git_deployer'
12
+ require 'robot-army-git-deploy/grit_ext'
@@ -0,0 +1,251 @@
1
+ module RobotArmy::GitDeployer
2
+ def self.included(base)
3
+ base.const_set(:DEPLOY_COUNT, 5)
4
+
5
+ base.class_eval do
6
+ method_options :target_revision => :string
7
+
8
+ desc "check", "Checks the deploy status"
9
+ def check(opts={})
10
+ update_server_refs
11
+
12
+ run_pager
13
+
14
+ say "Deployed Revisions"
15
+
16
+ deployed_revisions.each do |host, revision|
17
+ if revision
18
+ commit = commit_from_revision_or_abort(revision)
19
+ puts "%s: %s %s [%s]" % [
20
+ host,
21
+ color(commit.id_abbrev, :yellow),
22
+ commit.message.to_a.first.chomp,
23
+ commit.author.name]
24
+ else
25
+ puts "%s: %s %s" % [
26
+ host,
27
+ color('0000000', :yellow),
28
+ "(no deployed revision)"]
29
+ end
30
+ end
31
+
32
+ puts
33
+
34
+ say "On Deck"
35
+
36
+ if oldest_deployed_revision == target_revision
37
+ puts "Deployed revision is up to date"
38
+ elsif oldest_deployed_revision
39
+ shortlog "#{oldest_deployed_revision}..#{target_revision}"
40
+ diff "#{oldest_deployed_revision}..#{target_revision}"
41
+ else
42
+ shortlog target_revision, :root => true
43
+ diff target_revision, :root => true
44
+ end
45
+ end
46
+
47
+ desc "archive", "Write HEAD to a tgz file"
48
+ def archive
49
+ say "Archiving to #{archive_path}"
50
+ %x{git archive --format=tar #{target_revision} | gzip >#{archive_path}}
51
+ end
52
+
53
+ desc "stage", "Stages the locally-generated archive on each host"
54
+ def stage
55
+ revision = repo.commits.first.id
56
+
57
+ # create the destination directory
58
+ sudo do
59
+ FileUtils.mkdir_p(deploy_path)
60
+ FileUtils.chown(user, group, deploy_path)
61
+ end
62
+
63
+ say "Staging #{app} into #{deploy_path}"
64
+ cptemp(archive_path, :user => user) do |path|
65
+ %x{tar -xvz -f #{path} -C #{deploy_path}}
66
+ File.open(File.join(deploy_path, 'REVISION'), 'w') {|f| f << revision}
67
+ path # just so that we don't try to return a File and generate a warning
68
+ end
69
+ end
70
+
71
+ no_tasks do
72
+
73
+ def install
74
+ say "Installing #{app} into #{current_link}"
75
+ sudo do
76
+ FileUtils.rm_f(current_link)
77
+ FileUtils.ln_sf(deploy_path, current_link)
78
+ end
79
+ update_server_refs(true)
80
+ end
81
+
82
+ def cleanup
83
+ clean_temporary_files
84
+ clean_old_revisions
85
+ end
86
+
87
+ def clean_temporary_files
88
+ say "Cleaning up temporary files"
89
+ FileUtils.rm_f("#{app}-archive.tar.gz")
90
+ end
91
+
92
+ def clean_old_revisions
93
+ say "Cleaning up old revisions"
94
+ deploy_count = self.class.const_get(:DEPLOY_COUNT)
95
+
96
+ sudo do
97
+ deploy_paths = Dir.glob(File.join(deploy_root, '*')).sort
98
+ deploy_paths -= [current_link]
99
+ FileUtils.rm_rf(deploy_paths.first(deploy_paths.size - deploy_count)) if deploy_paths.size > deploy_count
100
+ end
101
+ end
102
+
103
+ desc "run", "Run a full deploy"
104
+ def run
105
+ archive
106
+ stage
107
+ install
108
+ cleanup
109
+ end
110
+
111
+ end
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ def repo
118
+ @repo ||= Grit::Repo.new(Dir.pwd)
119
+ end
120
+
121
+ def git
122
+ repo.git
123
+ end
124
+
125
+ def revfile
126
+ File.join(current_link, 'REVISION')
127
+ end
128
+
129
+ def deployed_revisions
130
+ @deployed_revisions ||= hosts.zip(Array(remote { File.read(revfile).chomp if File.exist?(revfile) }))
131
+ end
132
+
133
+ def clear_deployed_revisions_cache
134
+ @deployed_revisions = nil
135
+ end
136
+
137
+ def deployed_revisions_equal?
138
+ deployed_revisions.map{|host, revision| revision}.uniq.size == 1
139
+ end
140
+
141
+ def target_revision
142
+ options[:target_revision] || repo.head.commit
143
+ end
144
+
145
+ def oldest_deployed_revision
146
+ oldest_commit = deployed_revisions.
147
+ map {|host, revision| repo.commit(revision) if revision}.
148
+ compact.
149
+ sort.
150
+ first
151
+ return oldest_commit.id if oldest_commit
152
+ end
153
+
154
+ def update_server_refs(refresh=false)
155
+ clear_deployed_revisions_cache if refresh
156
+
157
+ deployed_revisions.each do |host, revision|
158
+ # thanks to doener in #git for this idea
159
+ git.update_ref({}, "refs/servers/#{host}", revision) if revision
160
+ end
161
+ end
162
+
163
+ def clear_deployed_refs
164
+ pairs = git.for_each_ref({:format => "%(objectname) %(refname)"}, 'refs/servers').to_a
165
+ pairs.map!{|line| line.chomp.split(' ')}
166
+ pairs.each do |objectname, refname|
167
+ git.update_ref({:d => true}, refname, objectname)
168
+ end
169
+ end
170
+
171
+ def archive_path
172
+ "#{app}-archive.tar.gz"
173
+ end
174
+
175
+ def deploy_root
176
+ File.expand_path(File.join(root, app))
177
+ end
178
+
179
+ def deploy_path
180
+ File.join(deploy_root, timestamp)
181
+ end
182
+
183
+ def current_link
184
+ File.join(deploy_root, 'current')
185
+ end
186
+
187
+ def color(*args)
188
+ (@highline ||= HighLine.new).color(*args)
189
+ end
190
+
191
+ def log(what, options={})
192
+ puts git.log({:pretty=>'oneline', :'abbrev-commit'=>true, :color=>true}.merge(options), what)
193
+ end
194
+
195
+ def shortlog(what, options={})
196
+ puts git.shortlog({:color=>true}.merge(options), what)
197
+ end
198
+
199
+ def diff(what, options={})
200
+ # dumb, dumb, dumb
201
+ puts git.send(:method_missing, :diff, {:stat => true, :color => true}.merge(options), what)
202
+ end
203
+
204
+ def deployed_revision
205
+ deployed_revisions.find{|host, revision| revision}.last
206
+ end
207
+
208
+ def commit_from_revision_or_abort(revision)
209
+ if commit = repo.commits(revision, 1).first
210
+ return commit
211
+ else
212
+ $stderr.puts "#{color('ERROR', :red)}: The deployed revision (#{color(revision, :yellow)}) was not found in your local repository. Perhaps you need to update first?"
213
+ exit(1)
214
+ end
215
+ end
216
+
217
+ def revfile
218
+ File.join(current_link, 'REVISION')
219
+ end
220
+
221
+ def timestamp
222
+ @timestamp ||= Time.now.utc.strftime("%Y-%m-%d-%H-%M-%S")
223
+ end
224
+
225
+
226
+ def run_pager
227
+ return if PLATFORM =~ /win32/
228
+ return unless STDOUT.tty?
229
+
230
+ read, write = IO.pipe
231
+
232
+ unless Kernel.fork # Child process
233
+ STDOUT.reopen(write)
234
+ STDERR.reopen(write) if STDERR.tty?
235
+ read.close
236
+ write.close
237
+ return
238
+ end
239
+
240
+ # Parent process, become pager
241
+ STDIN.reopen(read)
242
+ read.close
243
+ write.close
244
+
245
+ ENV['LESS'] = 'FSRX' # Don't page if the input is short enough
246
+
247
+ Kernel.select [STDIN] # Wait until we have input before we start the pager
248
+ pager = ENV['PAGER'] || 'less'
249
+ exec pager rescue exec "/bin/sh", "-c", pager
250
+ end
251
+ end
@@ -0,0 +1,18 @@
1
+ class Grit::Commit
2
+ include Comparable
3
+
4
+ def <=>(other)
5
+ raise ArgumentError unless other.is_a?(Grit::Commit)
6
+
7
+ if id == other.id
8
+ return 0
9
+ elsif not @repo.commits("#{id}..#{other.id}").empty?
10
+ return -1
11
+ elsif not @repo.commits("#{other.id}..#{id}").empty?
12
+ return 1
13
+ else
14
+ raise ArgumentError,
15
+ "#{other.inspect} is not an ancestor of #{self.inspect} or vice-versa, and are therefor not comparable"
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,230 @@
1
+ require File.dirname(__FILE__) + '/spec_helper'
2
+
3
+ class Deploy < RobotArmy::TaskMaster
4
+ include RobotArmy::GitDeployer
5
+
6
+ hosts %w[test1 test2 test3]
7
+
8
+ def remote(hosts=self.class.hosts)
9
+ hosts.size == 1 ? yield : hosts.map { yield }
10
+ end
11
+
12
+ alias_method :sudo, :remote
13
+
14
+ def root
15
+ "/opt"
16
+ end
17
+
18
+ def app
19
+ "test"
20
+ end
21
+
22
+ def user
23
+ "nobody"
24
+ end
25
+
26
+ def group
27
+ "nobody"
28
+ end
29
+
30
+ def color(str, color)
31
+ str
32
+ end
33
+
34
+ # make all methods public so we can test 'em easily
35
+ private_instance_methods.each { |m| public m }
36
+ end
37
+
38
+ class FakeCommit
39
+ attr_reader :id
40
+ include Comparable
41
+
42
+ def initialize(id)
43
+ @id = id
44
+ end
45
+
46
+ def <=>(other)
47
+ id <=> other.id
48
+ end
49
+ end
50
+
51
+ describe RobotArmy::GitDeployer do
52
+ before do
53
+ @deploy = Deploy.new
54
+ @deploy.options = (@options = {})
55
+ end
56
+
57
+ describe "tasks" do
58
+ it "defines a 'check' task" do
59
+ Deploy.tasks['check'].must be_an_instance_of(Thor::Task)
60
+ end
61
+
62
+ it "defines a 'run' task" do
63
+ Deploy.tasks['run'].must be_an_instance_of(Thor::Task)
64
+ end
65
+
66
+ it "defines a 'cleanup' task" do
67
+ Deploy.tasks['cleanup'].must be_an_instance_of(Thor::Task)
68
+ end
69
+
70
+ it "defines a 'install' task" do
71
+ Deploy.tasks['install'].must be_an_instance_of(Thor::Task)
72
+ end
73
+
74
+ it "defines a 'archive' task" do
75
+ Deploy.tasks['archive'].must be_an_instance_of(Thor::Task)
76
+ end
77
+
78
+ it "defines a 'stage' task" do
79
+ Deploy.tasks['stage'].must be_an_instance_of(Thor::Task)
80
+ end
81
+ end
82
+
83
+ describe "plumbing" do
84
+ describe "when the revfile exists on all hosts" do
85
+ before do
86
+ File.stub!(:exist?).with(@deploy.revfile).any_number_of_times.and_return(true)
87
+ File.stub!(:read).with(@deploy.revfile).any_number_of_times.and_return("abcde\n")
88
+ end
89
+
90
+ it "can get the list of deployed revisions" do
91
+ @deploy.deployed_revisions.must == [%w[test1 abcde], %w[test2 abcde], %w[test3 abcde]]
92
+ end
93
+ end
94
+ end
95
+
96
+ describe "when the revfile doesn't exist on a host" do
97
+ before do
98
+ File.stub!(:exist?).with(@deploy.revfile).and_return(true, true, false)
99
+ File.stub!(:read).with(@deploy.revfile).twice.and_return("abcde\n")
100
+ end
101
+
102
+ it "returns nil for that host's revision" do
103
+ @deploy.deployed_revisions.must == [%w[test1 abcde], %w[test2 abcde], ['test3', nil]]
104
+ end
105
+ end
106
+
107
+ describe "oldest_deployed_revision" do
108
+ describe "when all revisions are equal" do
109
+ before do
110
+ @deploy.stub!(:repo).and_return(stub(:repo, :commit => FakeCommit.new('abcde')))
111
+ end
112
+
113
+ it "sorts commits and returns the first (oldest) one" do
114
+ @deploy.
115
+ stub!(:deployed_revisions).
116
+ and_return([%w[test1 abcde], %w[test2 abcde], %w[test3 abcde]])
117
+
118
+ @deploy.oldest_deployed_revision.must == 'abcde'
119
+ end
120
+ end
121
+
122
+ describe "when not all revisions are equal" do
123
+ before do
124
+ @repo = stub(:repo)
125
+ @repo.
126
+ stub!(:commit).
127
+ and_return(FakeCommit.new('cdeab'), FakeCommit.new('abcde'), FakeCommit.new('deabc'))
128
+
129
+ @deploy.
130
+ stub!(:repo).
131
+ and_return(@repo)
132
+ end
133
+
134
+ it "sorts commits and returns the first (oldest) one" do
135
+ @deploy.
136
+ stub!(:deployed_revisions).
137
+ and_return([%w[test1 cdeab], %w[test2 abcde], %w[test3 deabc]])
138
+
139
+ @deploy.oldest_deployed_revision.must == 'abcde'
140
+ end
141
+ end
142
+ end
143
+
144
+ describe "target_revision" do
145
+ it "defaults to the current HEAD" do
146
+ @deploy.stub!(:repo).and_return(stub(:repo, :commits => [FakeCommit.new('abcde')]))
147
+ @deploy.target_revision.must == 'abcde'
148
+ end
149
+
150
+ it "can be set as an option" do
151
+ @options[:target_revision] = 'production'
152
+ @deploy.target_revision.must == 'production'
153
+ end
154
+ end
155
+
156
+ describe "commit_from_revision_or_abort" do
157
+ describe "given a revision in the repository" do
158
+ before do
159
+ @commit = FakeCommit.new('abcde')
160
+ @deploy.stub!(:repo).and_return(stub(:repo, :commits => [@commit]))
161
+ end
162
+
163
+ it "returns the commit for that revision" do
164
+ @deploy.commit_from_revision_or_abort('abcde').must == @commit
165
+ end
166
+ end
167
+
168
+ describe "given a revision not in the repository" do
169
+ before do
170
+ @deploy.stub!(:repo).and_return(stub(:repo, :commits => []))
171
+ end
172
+
173
+ it "warns about the missing commit and exits" do
174
+ @deploy.should_receive(:exit).with(1)
175
+ capture(:stderr) { @deploy.commit_from_revision_or_abort('abcde') }.
176
+ must == "ERROR: The deployed revision (abcde) was not found in your local repository. Perhaps you need to update first?\n"
177
+ end
178
+ end
179
+ end
180
+
181
+ describe "clean_old_revisions" do
182
+ def deployed_revision_paths_for_range(range, include_current=true)
183
+ (include_current ? [@deploy.current_link] : []) + range.map {|i| File.join(@deploy.deploy_root, "deploy-#{i}")}
184
+ end
185
+
186
+ describe "when there are no deployed revisions" do
187
+ before do
188
+ Dir.stub!(:glob).with(File.join(@deploy.deploy_root, '*')).and_return(deployed_revision_paths_for_range(1...1))
189
+ end
190
+
191
+ it "does not remove anything" do
192
+ FileUtils.should_not_receive(:rm_rf)
193
+ silence(:stdout) { @deploy.clean_old_revisions }
194
+ end
195
+ end
196
+
197
+ describe "when there are less than DEPLOY_COUNT deployed revisions" do
198
+ before do
199
+ Dir.stub!(:glob).with(File.join(@deploy.deploy_root, '*')).and_return(deployed_revision_paths_for_range(1...Deploy::DEPLOY_COUNT))
200
+ end
201
+
202
+ it "does not remove anything" do
203
+ FileUtils.should_not_receive(:rm_rf)
204
+ silence(:stdout) { @deploy.clean_old_revisions }
205
+ end
206
+ end
207
+
208
+ describe "when there are exactly DEPLOY_COUNT deployed revisions" do
209
+ before do
210
+ Dir.stub!(:glob).with(File.join(@deploy.deploy_root, '*')).and_return(deployed_revision_paths_for_range(1..Deploy::DEPLOY_COUNT))
211
+ end
212
+
213
+ it "does not remove anything" do
214
+ FileUtils.should_not_receive(:rm_rf)
215
+ silence(:stdout) { @deploy.clean_old_revisions }
216
+ end
217
+ end
218
+
219
+ describe "when there are more than DEPLOY_COUNT deployed revisions" do
220
+ before do
221
+ Dir.stub!(:glob).with(File.join(@deploy.deploy_root, '*')).and_return(deployed_revision_paths_for_range(1..(Deploy::DEPLOY_COUNT+2)))
222
+ end
223
+
224
+ it "removes enough of the oldest deployed revisions to get the count down to DEPLOY_COUNT" do
225
+ FileUtils.should_receive(:rm_rf).with(deployed_revision_paths_for_range(1..2, false)).exactly(@deploy.hosts.size).times
226
+ silence(:stdout) { @deploy.clean_old_revisions }
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,26 @@
1
+ $TESTING=true
2
+ load File.join(File.dirname(__FILE__), '..', 'lib', 'robot-army-git-deploy.rb')
3
+
4
+ module Spec::Expectations::ObjectExpectations
5
+ alias_method :must, :should
6
+ alias_method :must_not, :should_not
7
+ undef_method :should
8
+ undef_method :should_not
9
+ end
10
+
11
+ Spec::Runner.configure do |config|
12
+ def capture(stream)
13
+ begin
14
+ stream = stream.to_s
15
+ eval "$#{stream} = StringIO.new"
16
+ yield
17
+ result = eval("$#{stream}").string
18
+ ensure
19
+ eval("$#{stream} = #{stream.upcase}")
20
+ end
21
+
22
+ result
23
+ end
24
+
25
+ alias silence capture
26
+ end
metadata ADDED
@@ -0,0 +1,124 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: robot-army-git-deploy
3
+ version: !ruby/object:Gem::Version
4
+ prerelease: false
5
+ segments:
6
+ - 0
7
+ - 0
8
+ - 4
9
+ version: 0.0.4
10
+ platform: ruby
11
+ authors:
12
+ - Brian Donovan
13
+ autorequire:
14
+ bindir: bin
15
+ cert_chain: []
16
+
17
+ date: 2009-09-23 00:00:00 -07:00
18
+ default_executable:
19
+ dependencies:
20
+ - !ruby/object:Gem::Dependency
21
+ name: robot-army
22
+ prerelease: false
23
+ requirement: &id001 !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ segments:
28
+ - 0
29
+ - 1
30
+ - 7
31
+ version: 0.1.7
32
+ type: :runtime
33
+ version_requirements: *id001
34
+ - !ruby/object:Gem::Dependency
35
+ name: thor
36
+ prerelease: false
37
+ requirement: &id002 !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ segments:
42
+ - 0
43
+ - 11
44
+ - 3
45
+ version: 0.11.3
46
+ type: :runtime
47
+ version_requirements: *id002
48
+ - !ruby/object:Gem::Dependency
49
+ name: grit
50
+ prerelease: false
51
+ requirement: &id003 !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">"
54
+ - !ruby/object:Gem::Version
55
+ segments:
56
+ - 0
57
+ - 0
58
+ - 0
59
+ version: 0.0.0
60
+ type: :runtime
61
+ version_requirements: *id003
62
+ - !ruby/object:Gem::Dependency
63
+ name: highline
64
+ prerelease: false
65
+ requirement: &id004 !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">"
68
+ - !ruby/object:Gem::Version
69
+ segments:
70
+ - 0
71
+ - 0
72
+ - 0
73
+ version: 0.0.0
74
+ type: :runtime
75
+ version_requirements: *id004
76
+ description: Robot Army deployment with git repositories
77
+ email: brian@wesabe.com
78
+ executables: []
79
+
80
+ extensions: []
81
+
82
+ extra_rdoc_files:
83
+ - LICENSE
84
+ - README.markdown
85
+ files:
86
+ - LICENSE
87
+ - README.markdown
88
+ - Rakefile
89
+ - lib/robot-army-git-deploy.rb
90
+ - lib/robot-army-git-deploy/git_deployer.rb
91
+ - lib/robot-army-git-deploy/grit_ext.rb
92
+ has_rdoc: true
93
+ homepage: http://github.com/wesabe/robot-army-git-deploy
94
+ licenses: []
95
+
96
+ post_install_message:
97
+ rdoc_options:
98
+ - --charset=UTF-8
99
+ require_paths:
100
+ - lib
101
+ required_ruby_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ segments:
106
+ - 0
107
+ version: "0"
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ segments:
113
+ - 0
114
+ version: "0"
115
+ requirements: []
116
+
117
+ rubyforge_project: robot-army-git-deploy
118
+ rubygems_version: 1.3.6
119
+ signing_key:
120
+ specification_version: 3
121
+ summary: Robot Army deployment with git repositories
122
+ test_files:
123
+ - spec/git_deployer_spec.rb
124
+ - spec/spec_helper.rb