recipiez 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/History.txt ADDED
@@ -0,0 +1 @@
1
+ * Initial development - 9/1/08
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2008 Alastair Brunton
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.textile ADDED
@@ -0,0 +1,13 @@
1
+ h1. Deployment Recipiez
2
+
3
+ A collection of capistrano recipes for doing a whole host of things to servers.
4
+
5
+ * Apache vhosts
6
+ * Logrotate
7
+ * Monit
8
+ * Nginx Vhost
9
+ * Database syncing
10
+ * Rsyncing
11
+ * Chef
12
+ * Php Vhost
13
+ * Thin Setup
@@ -0,0 +1,58 @@
1
+ require 'mechanize'
2
+
3
+ module ActiveCollab
4
+
5
+ class Notifier
6
+
7
+ def initialize(options = {})
8
+
9
+ if keys_missing?(options)
10
+ raise StandardError, "You have not supplied all the required arguments in the hash. (base_url, project_id, ticket_id, email, password)"
11
+ end
12
+
13
+ @base_url = options[:base_url]
14
+ @project_id = options[:project_id]
15
+ @ticket_id = options[:ticket_id]
16
+ @mech = Mechanize.new
17
+ @email = options[:email]
18
+ @password = options[:password]
19
+ end
20
+
21
+ def say(message)
22
+ login
23
+ submit_ticket_comment(message)
24
+ end
25
+
26
+ def keys_missing?(options)
27
+ [:base_url, :project_id, :ticket_id, :email, :password].each do |key|
28
+ return true unless options.has_key?(key)
29
+ end
30
+ false
31
+ end
32
+
33
+ private
34
+
35
+ def login
36
+
37
+ @mech.get(@base_url + "/login") do |login_page|
38
+ form = login_page.forms.first
39
+
40
+ form['login[email]'] = @email
41
+ form['login[password]'] = @password
42
+
43
+ form.click_button
44
+ end
45
+ end
46
+
47
+ def submit_ticket_comment(message)
48
+ @mech.get(@base_url + "/projects/#{@project_id}/tickets/#{@ticket_id}") do |page|
49
+ page.form_with(:action => /\/projects\/#{@project_id}\/comments\/add/) do |comment_form|
50
+ comment_form['comment[body]'] = message
51
+ comment_form.click_button
52
+ end
53
+ end
54
+ end
55
+
56
+ end
57
+
58
+ end
data/lib/basecamp.rb ADDED
@@ -0,0 +1,486 @@
1
+ # the following are all standard ruby libraries
2
+ require 'net/https'
3
+ require 'yaml'
4
+ require 'date'
5
+ require 'time'
6
+
7
+ begin
8
+ require 'rubygems'
9
+ require 'xmlsimple'
10
+ rescue LoadError
11
+ abort <<-ERROR
12
+ The 'xml-simple' library could not be loaded. If you have RubyGems installed
13
+ you can install xml-simple by doing "gem install xml-simple".
14
+ ERROR
15
+ end
16
+
17
+ # An interface to the Basecamp web-services API. Usage is straightforward:
18
+ #
19
+ # session = Basecamp.new('your.basecamp.com', 'username', 'password')
20
+ # puts "projects: #{session.projects.length}"
21
+ class Basecamp
22
+
23
+ # A wrapper to encapsulate the data returned by Basecamp, for easier access.
24
+ class Record #:nodoc:
25
+ attr_reader :type
26
+
27
+ def initialize(type, hash)
28
+ @type = type
29
+ @hash = hash
30
+ end
31
+
32
+ def [](name)
33
+ name = dashify(name)
34
+ case @hash[name]
35
+ when Hash then
36
+ @hash[name] = (@hash[name].keys.length == 1 && Array === @hash[name].values.first) ?
37
+ @hash[name].values.first.map { |v| Record.new(@hash[name].keys.first, v) } :
38
+ Record.new(name, @hash[name])
39
+ else @hash[name]
40
+ end
41
+ end
42
+
43
+ def id
44
+ @hash["id"]
45
+ end
46
+
47
+ def attributes
48
+ @hash.keys
49
+ end
50
+
51
+ def respond_to?(sym)
52
+ super || @hash.has_key?(dashify(sym))
53
+ end
54
+
55
+ def method_missing(sym, *args)
56
+ if args.empty? && !block_given? && respond_to?(sym)
57
+ self[sym]
58
+ else
59
+ super
60
+ end
61
+ end
62
+
63
+ def to_s
64
+ "\#<Record(#{@type}) #{@hash.inspect[1..-2]}>"
65
+ end
66
+
67
+ def inspect
68
+ to_s
69
+ end
70
+
71
+ private
72
+
73
+ def dashify(name)
74
+ name.to_s.tr("_", "-")
75
+ end
76
+ end
77
+
78
+ # A wrapper to represent a file that should be uploaded. This is used so that
79
+ # the form/multi-part encoder knows when to encode a field as a file, versus
80
+ # when to encode it as a simple field.
81
+ class FileUpload
82
+ attr_reader :filename, :content
83
+
84
+ def initialize(filename, content)
85
+ @filename = filename
86
+ @content = content
87
+ end
88
+ end
89
+
90
+ attr_accessor :use_xml
91
+
92
+ # Connects
93
+ def initialize(url, user_name, password, use_ssl = false)
94
+ @use_xml = false
95
+ @user_name, @password = user_name, password
96
+ connect!(url, use_ssl)
97
+ end
98
+
99
+ # Return the list of all accessible projects.
100
+ def projects
101
+ records "project", "/project/list"
102
+ end
103
+
104
+ # Returns the list of message categories for the given project
105
+ def message_categories(project_id)
106
+ records "post-category", "/projects/#{project_id}/post_categories"
107
+ end
108
+
109
+ # Returns the list of file categories for the given project
110
+ def file_categories(project_id)
111
+ records "attachment-category", "/projects/#{project_id}/attachment_categories"
112
+ end
113
+
114
+ # Return information for the company with the given id
115
+ def company(id)
116
+ record "/contacts/company/#{id}"
117
+ end
118
+
119
+ # Return an array of the people in the given company. If the project-id is
120
+ # given, only people who have access to the given project will be returned.
121
+ def people(company_id, project_id=nil)
122
+ url = project_id ? "/projects/#{project_id}" : ""
123
+ url << "/contacts/people/#{company_id}"
124
+ records "person", url
125
+ end
126
+
127
+ # Return information about the person with the given id
128
+ def person(id)
129
+ record "/contacts/person/#{id}"
130
+ end
131
+
132
+ # Return information about the message(s) with the given id(s). The API
133
+ # limits you to requesting 25 messages at a time, so if you need to get more
134
+ # than that, you'll need to do it in multiple requests.
135
+ def message(*ids)
136
+ result = records("post", "/msg/get/#{ids.join(",")}")
137
+ result.length == 1 ? result.first : result
138
+ end
139
+
140
+ # Returns a summary of all messages in the given project (and category, if
141
+ # specified). The summary is simply the title and category of the message,
142
+ # as well as the number of attachments (if any).
143
+ def message_list(project_id, category_id=nil)
144
+ url = "/projects/#{project_id}/msg"
145
+ url << "/cat/#{category_id}" if category_id
146
+ url << "/archive"
147
+
148
+ records "post", url
149
+ end
150
+
151
+ # Create a new message in the given project. The +message+ parameter should
152
+ # be a hash. The +email_to+ parameter must be an array of person-id's that
153
+ # should be notified of the post.
154
+ #
155
+ # If you want to add attachments to the message, the +attachments+ parameter
156
+ # should be an array of hashes, where each has has a :name key (optional),
157
+ # and a :file key (required). The :file key must refer to a Basecamp::FileUpload
158
+ # instance.
159
+ #
160
+ # msg = session.post_message(158141,
161
+ # { :title => "Requirements",
162
+ # :body => "Here are the requirements documents you asked for.",
163
+ # :category_id => 2301121 },
164
+ # [john.id, martha.id],
165
+ # [ { :name => "Primary Requirements",
166
+ # :file => Basecamp::FileUpload.new('primary.doc", File.read('primary.doc')) },
167
+ # { :file => Basecamp::FileUpload.new('other.doc', File.read('other.doc')) } ])
168
+ def post_message(project_id, message, notify=[], attachments=[])
169
+ prepare_attachments(attachments)
170
+ record "/projects/#{project_id}/msg/create",
171
+ :post => message,
172
+ :notify => notify,
173
+ :attachments => attachments
174
+ end
175
+
176
+ # Edit the message with the given id. The +message+ parameter should
177
+ # be a hash. The +email_to+ parameter must be an array of person-id's that
178
+ # should be notified of the post.
179
+ #
180
+ # The +attachments+ parameter, if used, should be the same as described for
181
+ # #post_message.
182
+ def update_message(id, message, notify=[], attachments=[])
183
+ prepare_attachments(attachments)
184
+ record "/msg/update/#{id}",
185
+ :post => message,
186
+ :notify => notify,
187
+ :attachments => attachments
188
+ end
189
+
190
+ # Deletes the message with the given id, and returns it.
191
+ def delete_message(id)
192
+ record "/msg/delete/#{id}"
193
+ end
194
+
195
+ # Return a list of the comments for the specified message.
196
+ def comments(post_id)
197
+ records "comment", "/msg/comments/#{post_id}"
198
+ end
199
+
200
+ # Retrieve a specific comment
201
+ def comment(id)
202
+ record "/msg/comment/#{id}"
203
+ end
204
+
205
+ # Add a new comment to a message. +comment+ must be a hash describing the
206
+ # comment. You can add attachments to the comment, too, by giving them in
207
+ # an array. See the #post_message method for a description of how to do that.
208
+ def create_comment(post_id, comment, attachments=[])
209
+ prepare_attachments(attachments)
210
+ record "/msg/create_comment", :comment => comment.merge(:post_id => post_id),
211
+ :attachments => attachments
212
+ end
213
+
214
+ # Update the given comment. Attachments follow the same format as #post_message.
215
+ def update_comment(id, comment, attachments=[])
216
+ prepare_attachments(attachments)
217
+ record "/msg/update_comment", :comment_id => id,
218
+ :comment => comment, :attachments => attachments
219
+ end
220
+
221
+ # Deletes (and returns) the given comment.
222
+ def delete_comment(id)
223
+ record "/msg/delete_comment/#{id}"
224
+ end
225
+
226
+ # =========================================================================
227
+ # TODO LISTS AND ITEMS
228
+ # =========================================================================
229
+
230
+ # Marks the given item completed.
231
+ def complete_item(id)
232
+ record "/todos/complete_item/#{id}"
233
+ end
234
+
235
+ # Marks the given item uncompleted.
236
+ def uncomplete_item(id)
237
+ record "/todos/uncomplete_item/#{id}"
238
+ end
239
+
240
+ # Creates a new to-do item.
241
+ def create_item(list_id, content, responsible_party=nil, notify=true)
242
+ record "/todos/create_item/#{list_id}",
243
+ :content => content, :responsible_party => responsible_party,
244
+ :notify => notify
245
+ end
246
+
247
+ # Creates a new list using the given hash of list metadata.
248
+ def create_list(project_id, list)
249
+ record "/projects/#{project_id}/todos/create_list", list
250
+ end
251
+
252
+ # Deletes the given item from it's parent list.
253
+ def delete_item(id)
254
+ record "/todos/delete_item/#{id}"
255
+ end
256
+
257
+ # Deletes the given list and all of its items.
258
+ def delete_list(id)
259
+ record "/todos/delete_list/#{id}"
260
+ end
261
+
262
+ # Retrieves the specified list, and all of its items.
263
+ def get_list(id)
264
+ record "/todos/list/#{id}"
265
+ end
266
+
267
+ # Return all lists for a project. If complete is true, only completed lists
268
+ # are returned. If complete is false, only uncompleted lists are returned.
269
+ def lists(project_id, complete=nil)
270
+ records "todo-list", "/projects/#{project_id}/todos/lists", :complete => complete
271
+ end
272
+
273
+ # Repositions an item to be at the given position in its list
274
+ def move_item(id, to)
275
+ record "/todos/move_item/#{id}", :to => to
276
+ end
277
+
278
+ # Repositions a list to be at the given position in its project
279
+ def move_list(id, to)
280
+ record "/todos/move_list/#{id}", :to => to
281
+ end
282
+
283
+ # Updates the given item
284
+ def update_item(id, content, responsible_party=nil, notify=true)
285
+ record "/todos/update_item/#{id}",
286
+ :item => { :content => content }, :responsible_party => responsible_party,
287
+ :notify => notify
288
+ end
289
+
290
+ # Updates the given list's metadata
291
+ def update_list(id, list)
292
+ record "/todos/update_list/#{id}", :list => list
293
+ end
294
+
295
+ # =========================================================================
296
+ # MILESTONES
297
+ # =========================================================================
298
+
299
+ # Complete the milestone with the given id
300
+ def complete_milestone(id)
301
+ record "/milestones/complete/#{id}"
302
+ end
303
+
304
+ # Create a new milestone for the given project. +data+ must be hash of the
305
+ # values to set, including +title+, +deadline+, +responsible_party+, and
306
+ # +notify+.
307
+ def create_milestone(project_id, data)
308
+ create_milestones(project_id, [data]).first
309
+ end
310
+
311
+ # As #create_milestone, but can create multiple milestones in a single
312
+ # request. The +milestones+ parameter must be an array of milestone values as
313
+ # descrbed in #create_milestone.
314
+ def create_milestones(project_id, milestones)
315
+ records "milestone", "/projects/#{project_id}/milestones/create", :milestone => milestones
316
+ end
317
+
318
+ # Destroys the milestone with the given id.
319
+ def delete_milestone(id)
320
+ record "/milestones/delete/#{id}"
321
+ end
322
+
323
+ # Returns a list of all milestones for the given project, optionally filtered
324
+ # by whether they are completed, late, or upcoming.
325
+ def milestones(project_id, find="all")
326
+ records "milestone", "/projects/#{project_id}/milestones/list", :find => find
327
+ end
328
+
329
+ # Uncomplete the milestone with the given id
330
+ def uncomplete_milestone(id)
331
+ record "/milestones/uncomplete/#{id}"
332
+ end
333
+
334
+ # Updates an existing milestone.
335
+ def update_milestone(id, data, move=false, move_off_weekends=false)
336
+ record "/milestones/update/#{id}", :milestone => data,
337
+ :move_upcoming_milestones => move,
338
+ :move_upcoming_milestones_off_weekends => move_off_weekends
339
+ end
340
+
341
+ # Make a raw web-service request to Basecamp. This will return a Hash of
342
+ # Arrays of the response, and may seem a little odd to the uninitiated.
343
+ def request(path, parameters = {}, second_try = false)
344
+ response = post(path, convert_body(parameters), "Content-Type" => content_type)
345
+
346
+ if response.code.to_i / 100 == 2
347
+ result = XmlSimple.xml_in(response.body, 'keeproot' => true,
348
+ 'contentkey' => '__content__', 'forcecontent' => true)
349
+ typecast_value(result)
350
+ elsif response.code == "302" && !second_try
351
+ connect!(@url, !@use_ssl)
352
+ request(path, parameters, true)
353
+ else
354
+ raise "#{response.message} (#{response.code})"
355
+ end
356
+ end
357
+
358
+ # A convenience method for wrapping the result of a query in a Record
359
+ # object. This assumes that the result is a singleton, not a collection.
360
+ def record(path, parameters={})
361
+ result = request(path, parameters)
362
+ (result && !result.empty?) ? Record.new(result.keys.first, result.values.first) : nil
363
+ end
364
+
365
+ # A convenience method for wrapping the result of a query in Record
366
+ # objects. This assumes that the result is a collection--any singleton
367
+ # result will be wrapped in an array.
368
+ def records(node, path, parameters={})
369
+ result = request(path, parameters).values.first or return []
370
+ result = result[node] or return []
371
+ result = [result] unless Array === result
372
+ result.map { |row| Record.new(node, row) }
373
+ end
374
+
375
+ private
376
+
377
+ def connect!(url, use_ssl)
378
+ @use_ssl = use_ssl
379
+ @url = url
380
+ @connection = Net::HTTP.new(url, use_ssl ? 443 : 80)
381
+ @connection.use_ssl = @use_ssl
382
+ @connection.verify_mode = OpenSSL::SSL::VERIFY_NONE if @use_ssl
383
+ end
384
+
385
+ def convert_body(body)
386
+ body = use_xml ? body.to_xml : body.to_yaml
387
+ end
388
+
389
+ def content_type
390
+ use_xml ? "application/xml" : "application/x-yaml"
391
+ end
392
+
393
+ def post(path, body, header={})
394
+ request = Net::HTTP::Post.new(path, header.merge('Accept' => 'application/xml'))
395
+ request.basic_auth(@user_name, @password)
396
+ @connection.request(request, body)
397
+ end
398
+
399
+ def store_file(contents)
400
+ response = post("/upload", contents, 'Content-Type' => 'application/octet-stream',
401
+ 'Accept' => 'application/xml')
402
+
403
+ if response.code == "200"
404
+ result = XmlSimple.xml_in(response.body, 'keeproot' => true, 'forcearray' => false)
405
+ return result["upload"]["id"]
406
+ else
407
+ raise "Could not store file: #{response.message} (#{response.code})"
408
+ end
409
+ end
410
+
411
+ def typecast_value(value)
412
+ case value
413
+ when Hash
414
+ if value.has_key?("__content__")
415
+ content = translate_entities(value["__content__"]).strip
416
+ case value["type"]
417
+ when "integer" then content.to_i
418
+ when "boolean" then content == "true"
419
+ when "datetime" then Time.parse(content)
420
+ when "date" then Date.parse(content)
421
+ else content
422
+ end
423
+ # a special case to work-around a bug in XmlSimple. When you have an empty
424
+ # tag that has an attribute, XmlSimple will not add the __content__ key
425
+ # to the returned hash. Thus, we check for the presense of the 'type'
426
+ # attribute to look for empty, typed tags, and simply return nil for
427
+ # their value.
428
+ elsif value.keys == %w(type)
429
+ nil
430
+ elsif value["nil"] == "true"
431
+ nil
432
+ # another special case, introduced by the latest rails, where an array
433
+ # type now exists. This is parsed by XmlSimple as a two-key hash, where
434
+ # one key is 'type' and the other is the actual array value.
435
+ elsif value.keys.length == 2 && value["type"] == "array"
436
+ value.delete("type")
437
+ typecast_value(value)
438
+ else
439
+ value.empty? ? nil : value.inject({}) do |h,(k,v)|
440
+ h[k] = typecast_value(v)
441
+ h
442
+ end
443
+ end
444
+ when Array
445
+ value.map! { |i| typecast_value(i) }
446
+ case value.length
447
+ when 0 then nil
448
+ when 1 then value.first
449
+ else value
450
+ end
451
+ else
452
+ raise "can't typecast #{value.inspect}"
453
+ end
454
+ end
455
+
456
+ def translate_entities(value)
457
+ value.gsub(/&lt;/, "<").
458
+ gsub(/&gt;/, ">").
459
+ gsub(/&quot;/, '"').
460
+ gsub(/&apos;/, "'").
461
+ gsub(/&amp;/, "&")
462
+ end
463
+
464
+ def prepare_attachments(list)
465
+ (list || []).each do |data|
466
+ upload = data[:file]
467
+ id = store_file(upload.content)
468
+ data[:file] = { :file => id,
469
+ :content_type => "application/octet-stream",
470
+ :original_filename => upload.filename }
471
+ end
472
+ end
473
+ end
474
+
475
+ # A minor hack to let Xml-Simple serialize symbolic keys in hashes
476
+ class Symbol
477
+ def [](*args)
478
+ to_s[*args]
479
+ end
480
+ end
481
+
482
+ class Hash
483
+ def to_xml
484
+ XmlSimple.xml_out({:request => self}, 'keeproot' => true, 'noattr' => true)
485
+ end
486
+ end
@@ -0,0 +1,34 @@
1
+ # A class to update basecamp after a successful deployment
2
+ require 'basecamp'
3
+ class BasecampNotifier
4
+
5
+ def initialize(application, rails_env, options = {}, current_rev = 0, rev_log = '')
6
+
7
+ if keys_missing?(options)
8
+ raise StandardError, 'You have not supplied all arguments in the hash (:username, :password, :domain, :category_id, :project_id)'
9
+ end
10
+
11
+ @project_id = options[:project_id]
12
+ @category_id = options[:category_id]
13
+ @current_revision = current_rev
14
+ @application = application
15
+ @rails_env = rails_env
16
+ @revision_log = rev_log
17
+ @basecamp = Basecamp.new(options[:domain], options[:username], options[:password], true)
18
+ end
19
+
20
+ def keys_missing?(options)
21
+ [:domain, :username, :password, :category_id, :project_id].each do |key|
22
+ return true unless options.has_key?(key)
23
+ end
24
+ false
25
+ end
26
+
27
+ def notify
28
+ message = {:title => "#{@application} rev. #{@current_revision} deployed to #{@rails_env}",
29
+ :body => "#{@application} has been successfully deployed to #{@rails_env} <br /> #{@revision_log}",
30
+ :category_id => @category_id}
31
+ @basecamp.post_message(@project_id, message)
32
+ end
33
+
34
+ end
data/recipes/apache.rb ADDED
@@ -0,0 +1,41 @@
1
+ namespace :apache do
2
+
3
+ desc "Setup passenger vhost"
4
+ task :passenger_vhost do
5
+ logger.info "generating .conf file"
6
+ logger.info "placing #{application}.conf on remote server"
7
+ apache_conf = "/etc/apache2/sites-available/#{application}"
8
+ put render("passenger_vhost", binding), "#{application}.conf"
9
+ sudo "mv #{application}.conf #{apache_conf}"
10
+ sudo "a2ensite #{application}"
11
+ sudo "/etc/init.d/apache2 reload"
12
+ end
13
+
14
+
15
+ desc "PHP Vhost Setup"
16
+ task :php_vhost do
17
+
18
+ unless defined? apache_port
19
+ set :apache_port, '80'
20
+ end
21
+
22
+ logger.info "generating .conf file"
23
+ logger.info "placing #{application}.conf on remote server"
24
+ apache_conf = "/etc/apache2/sites-available/#{application}"
25
+ put render("php_vhost", binding), "#{application}.conf"
26
+ sudo "mv #{application}.conf #{apache_conf}"
27
+ sudo "a2ensite #{application}"
28
+ sudo "/etc/init.d/apache2 reload"
29
+ end
30
+
31
+
32
+ desc "enable php"
33
+ task :enable_php do
34
+ put render("php_handler", binding), "phphandler.conf"
35
+ apache_handler = "/etc/apache2/conf.d/phphandler"
36
+ sudo "mv phphandler.conf #{apache_handler}"
37
+ sudo "/etc/init.d/apache2 force-reload"
38
+ end
39
+
40
+
41
+ end
data/recipes/chef.rb ADDED
@@ -0,0 +1,8 @@
1
+ namespace :chef do
2
+
3
+ desc "Runs chef client on all the app servers"
4
+ task :client do
5
+ sudo "chef-client"
6
+ end
7
+
8
+ end