alcapon 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (5) hide show
  1. data/README.md +24 -2
  2. data/bin/capezit +34 -8
  3. data/lib/capez.rb +260 -37
  4. data/lib/ext/spinner.rb +26 -0
  5. metadata +17 -3
data/README.md CHANGED
@@ -1,8 +1,30 @@
1
1
  # AlCapON : Enable Capistrano for your eZ Publish installations
2
2
 
3
- AlCapON is a simple recipe for Capistrano, the well-known deployment toolbox. It helps you dealing with simple task such as pushing your code to your webserver(s), clearing cache, etc.
3
+ AlCapON is a simple recipe for Capistrano, the well-known deployment toolbox.
4
+ It helps you dealing with simple task such as pushing your code to your
5
+ webserver(s), clearing cache, etc.
4
6
 
5
- IMPORTANT: this package is currently under development, please consider testing it on a preproduction environment before going further. Please also read the "Known bugs" section carefully.
7
+ IMPORTANT: this package is currently under development, please consider testing
8
+ it on a preproduction environment before going further. Please also do read the
9
+ "Known bugs" section carefully.
10
+
11
+ ## Changelog
12
+
13
+ ### 0.3.x
14
+
15
+ - added the possibility to trigger rename and in-file replace operations
16
+ during the deployment (see the generated file
17
+ config/deploy/production.rb after running the capezit command)
18
+
19
+ - major changes in permissions management for the var/ folders. Previous
20
+ versions tried to manage different cases by using sudo commands but I'm
21
+ convinced that it is not the right place to do that. Permissions have to be
22
+ handled by sysadmin, not Capistrano.
23
+ This will be improved, maybe simplified again, in next versions.
24
+ In consequence, you might experienced some issues, but please, let me know.
25
+
26
+ - usage of siteaccess_list in ezpublish.rb is deprecated as of 0.3.0. Please
27
+ use storage_directories instead (see issue #2)
6
28
 
7
29
  ## Requirements, installation & co
8
30
 
data/bin/capezit CHANGED
@@ -69,6 +69,14 @@ files = {
69
69
  # Need if you want to deploy somewhere where sudo is needed
70
70
  default_run_options[:pty] = true
71
71
 
72
+ # Set debug level to IMPORTANT only
73
+ # Comment if you want to get more debug outputs
74
+ logger.level = Logger::IMPORTANT
75
+
76
+ # Override default feature (not needed for eZ Publish)
77
+ # If turned on, you will get a warning during deployment but it should not be aborted
78
+ set :normalize_asset_timestamps, false
79
+
72
80
  # Use this to use your ssh keys
73
81
  # (you might need to run ssh-add /path/to/your/deploy_key before)
74
82
  ssh_options[:forward_agent] = true
@@ -99,8 +107,9 @@ files = {
99
107
  # This file contains eZ Publish adjustable variables depending on your custom setup
100
108
 
101
109
  # Your webserver user and group, used to chmod directories just after deploy:setup
102
- set :webserver_user, "apache"
103
- set :webserver_group, "apache"
110
+ # By default, this is the user as the one used to connect via SSH
111
+ set :webserver_user, :user
112
+ set :webserver_group, :user
104
113
 
105
114
  # If true, will always turn your webserver offline
106
115
  # Requires a specific rewrite rule (see documentation)
@@ -117,15 +126,22 @@ files = {
117
126
  # your var directory (2012.5 for instance)
118
127
  set :cache_purge, false
119
128
 
120
- # Siteaccess list, needed for setup & shared directory sync
121
- #set :siteaccess_list, [ "ezflow_site" ]
129
+ # A list of var directories which will handle siteaccess's specific assets, such as
130
+ # the storage
131
+ #set :storage_directories, [ "ezflow_site" ]
122
132
 
123
133
  # Set this to tell Capistrano with which host you want to sync your local storage dir
124
134
  #set :shared_host, "domain.com"
125
135
 
136
+ # Changes the group of shared_children items (by default, webserver_group is used)
137
+ # If not set, permissions remain unchanged
138
+ #set :shared_children_group, "#{webserver_group}"
139
+
126
140
  # Which autoloads to generate. By default, regenerates extensions and
127
141
  # kernel-override autoloads
128
142
  # Possible values : see bin/php/ezpgenerateautoloads.php --help
143
+ # Feature can be disabled by using :
144
+ #set :autoload_list, []
129
145
  set :autoload_list, [ "extension", "kernel-override" ]
130
146
 
131
147
  # TODO : use yml files to manage database credentials securely
@@ -161,8 +177,8 @@ files = {
161
177
  #set :admin_runner, "sudouser"
162
178
 
163
179
  # This is used for permissions related tasks
164
- #set :webserver_user, "www-data"
165
- #set :webserver_group, "www-data"
180
+ #set :webserver_user, :user
181
+ #set :webserver_group, :user
166
182
  FILE
167
183
 
168
184
  "#{alcapon_path}/config/deploy/production.rb" => unindent(<<-FILE)
@@ -175,8 +191,18 @@ files = {
175
191
  #set :admin_runner, "sudouser"
176
192
 
177
193
  # This is used for permissions related tasks
178
- #set :webserver_user, "apache"
179
- #set :webserver_group, "apache"
194
+ #set :webserver_user, :user
195
+ #set :webserver_group, :user
196
+
197
+ #set :file_changes, {
198
+ # 'settings/override/site.ini.append.dist' => {
199
+ # 'rename' => 'settings/override/site.ini.append.php',
200
+ # 'replace' => {
201
+ # '@tokens_database_host@' => 'prod-dbs',
202
+ # },
203
+ # }
204
+ #}
205
+
180
206
  FILE
181
207
  }
182
208
 
data/lib/capez.rb CHANGED
@@ -1,16 +1,60 @@
1
1
  load_paths.push File.expand_path('../', __FILE__)
2
2
  load 'db.rb'
3
+ require 'colored'
3
4
 
4
5
  # This will simply do chmod g+w on all dir
5
6
  # See task :setup
6
7
  set :group_writable, true
7
8
 
9
+ # triggered after all recipes have loaded
10
+ on :load do
11
+ if( fetch( :siteaccess_list, nil ) != nil )
12
+ abort "The usage of siteaccess_list in ezpublish.rb is deprecated as of 0.3.0.\nPlease use storage_directories instead".red
13
+ end
14
+ end
15
+
16
+ before "deploy:setup" do
17
+ print_dotted( "--> Creating default directories" )
18
+ end
19
+
8
20
  after "deploy:setup", :roles => :web do
21
+ puts( " OK".green )
22
+ print_dotted( "--> Fixing permissions on deployment directory" )
23
+ try_sudo( "chown -R #{user} #{deploy_to}" ) # if not code checkout cannot be done :/
24
+ puts( " OK".green )
9
25
  capez.var.init_shared
10
26
  end
11
27
 
12
- after "deploy:update", :roles => :web do
13
- # We don't need to clear the cache anymore but a warmup might be needed
28
+ before "deploy:update_code" do
29
+ puts( "\n*** Building release ***" )
30
+ puts( "Started at " + Time.now.utc.strftime("%H:%M:%S") )
31
+ print_dotted( "--> Updating code", :sol => true )
32
+ end
33
+
34
+ after "deploy:update_code" do
35
+ puts( "\n*** Release ready ***".green )
36
+ puts( "Finished at " + Time.now.utc.strftime("%H:%M:%S") )
37
+ end
38
+
39
+ before "deploy:finalize_update" do
40
+ puts( " OK".green )
41
+ # Needed if you want to create extra shared directories under var/ with
42
+ # set :shared_children, [ "var/something",
43
+ # "var/something_else" ]
44
+ # Note that :shared_children creates a folder within shared which name is
45
+ # the last path element (ie: something or something_else) => that's why
46
+ # we cannot use it to create siteaccess storages (var/siteaccess/storage)
47
+ run( "mkdir #{latest_release}/var" )
48
+ end
49
+
50
+ after "deploy:finalize_update" do
51
+ if fetch( :shared_children_group, false )
52
+ shared_children.map { |d| run( "chgrp -R #{shared_children_group} #{shared_path}/#{d.split('/').last}") }
53
+ end
54
+ capez.var.init_release
55
+ capez.var.link
56
+ capez.settings.deploy
57
+ capez.autoloads.generate
14
58
  #capez.cache.clear
15
59
  end
16
60
 
@@ -23,15 +67,16 @@ after "deploy", :roles => :web do
23
67
  deploy.web.enable
24
68
  end
25
69
 
26
- namespace :deploy do
70
+ before "deploy:create_symlink" do
71
+ print_dotted( "--> Going live (symlink)", :sol => true )
72
+ end
27
73
 
28
- desc <<-DESC
29
- Finalize the update by creating symlink var -> shared/var
30
- DESC
31
- task :finalize_update do
32
- capez.var.link
33
- capez.autoloads.generate
34
- end
74
+ after "deploy:create_symlink" do
75
+ puts( " OK".green )
76
+ end
77
+
78
+ # Default behavior overrides
79
+ namespace :deploy do
35
80
 
36
81
  namespace :web do
37
82
  desc <<-DESC
@@ -61,6 +106,116 @@ end
61
106
 
62
107
 
63
108
  namespace :capez do
109
+ namespace :settings do
110
+
111
+ def make_file_changes( options={} )
112
+
113
+ default_options = { :locally => false }
114
+ options = default_options.merge( options )
115
+
116
+ puts( "\n--> File operations" )
117
+
118
+ unless !(file_changes = get_file_changes) then
119
+
120
+ path = options[:locally] ? "" : "#{latest_release}/"
121
+
122
+ changes = 0
123
+ renames = 0
124
+ errors = []
125
+ messages = []
126
+
127
+ print_dotted( "execution", :eol_msg => (options[:locally] ? "local" : "distant" ), :eol => true, :max_length => 25 )
128
+ print_dotted( "files count", :eol_msg => "#{file_changes.count}", :eol => true, :max_length => 25 )
129
+
130
+ # process each files
131
+ print( "progress " )
132
+ file_changes.each { |filename,operations|
133
+
134
+ print( "." )
135
+
136
+ target_filename = filename
137
+ renamed = false
138
+
139
+ # rename operation is caught and executed at first
140
+ if operations.has_key?("rename")
141
+ if( target_filename != operations['rename'] )
142
+ target_filename = operations['rename']
143
+ cmd = "if [ -f #{path}#{filename} ]; then cp #{path}#{filename} #{path}#{target_filename}; fi;"
144
+ options[:locally] ? run_locally( "#{cmd}" ) : run( "#{cmd}" )
145
+ renames += 1
146
+ else
147
+ target_filename = operations['rename']
148
+ errors += ["target and original name are the same (#{target_filename})"]
149
+ end
150
+ end
151
+
152
+ operations.each { |operation,value|
153
+ case operation
154
+ when 'rename'
155
+ when 'replace'
156
+
157
+ if( value.count > 0 )
158
+
159
+ # download file if necessary
160
+ if options[:locally]
161
+ tmp_filename = target_filename
162
+ else
163
+ tmp_filename = target_filename+".tmp"
164
+ get "#{path}#{target_filename}", tmp_filename
165
+ end
166
+
167
+ text = File.read(tmp_filename)
168
+ value.each { |search,replace|
169
+ changes += 1
170
+ text = text.gsub( "#{search}", "#{replace}" )
171
+ }
172
+ File.open(tmp_filename, "w") {|file| file.write(text) }
173
+
174
+ # upload and remove temporary file
175
+ if !options[:locally]
176
+ run( "if [ -f #{target_filename} ]; then rm #{target_filename}; fi;" )
177
+ upload( tmp_filename, "#{path}#{target_filename}" )
178
+ run_locally( "rm #{tmp_filename}" )
179
+ end
180
+ end
181
+ else
182
+ errors += ( "operation '#{operation}' supported" )
183
+ end
184
+ }
185
+ }
186
+ puts " done".green
187
+
188
+ # stats
189
+ print_dotted( "files renamed", :eol_msg => "#{renames}", :eol => true, :max_length => 25 )
190
+ print_dotted( "changes count", :eol_msg => "#{changes}", :eol => true, :max_length => 25 )
191
+ print_dotted( "changes avg / file", :max_length => 25, :eol_msg => ( file_changes.count > 0 ? "#{changes/file_changes.count}" : "" ), :eol => true )
192
+ messages.each { |msg| puts( "#{msg}") }
193
+ puts( "errors : ".red ) unless errors.count == 0
194
+ errors.each { |msg| puts( "- #{msg}".red ) }
195
+ else
196
+ puts( "No file changes needs to be applied. Please set :file_changes".blue )
197
+ end
198
+ end
199
+
200
+ desc <<-DESC
201
+ Makes some file level operations if needed (rename, replace)
202
+ DESC
203
+ task :deploy, :roles => :web do
204
+ make_file_changes
205
+ end
206
+
207
+ desc <<-DESC
208
+ [local] Makes some file level operations if needed (rename, replace)
209
+ DESC
210
+ task :deploy_locally, :roles => :web do
211
+ make_file_changes( :locally => true )
212
+ end
213
+
214
+ def get_file_changes
215
+ return fetch( :file_changes, false )
216
+ end
217
+
218
+ end
64
219
 
65
220
  namespace :cache do
66
221
  desc <<-DESC
@@ -70,10 +225,12 @@ namespace :capez do
70
225
  # Multiple server platform are supposed to use a cluster configuration (eZDFS/eZDBFS)
71
226
  # and cache management is done via expiry.php which is managed by the cluster API
72
227
  task :clear, :roles => :web, :only => { :primary => true } do
73
- on_rollback do
74
- clear
75
- end
76
- cache_list.each { |cache_tag| capture "cd #{current_path} && sudo -u #{webserver_user} php bin/php/ezcache.php --clear-tag=#{cache_tag}#{' --purge' if cache_purge}" }
228
+ puts( "\n--> Clearing caches #{'with --purge'.red if cache_purge}" )
229
+ cache_list.each { |cache_tag|
230
+ print_dotted( "#{cache_tag}" )
231
+ capture "cd #{current_path} && sudo -u #{webserver_user} php bin/php/ezcache.php --clear-tag=#{cache_tag}#{' --purge' if cache_purge}"
232
+ puts( " OK".green )
233
+ }
77
234
  end
78
235
  end
79
236
 
@@ -82,29 +239,65 @@ namespace :capez do
82
239
  Creates the needed folder within your remote(s) var directories
83
240
  DESC
84
241
  task :init_shared, :roles => :web do
242
+ puts( "--> Creating eZ Publish var directories" )
243
+ print_dotted( "var " )
244
+ run( "mkdir -p #{shared_path}/var" )
245
+ puts( " OK".green )
246
+
247
+ print_dotted( "var/storage" )
85
248
  run( "mkdir -p #{shared_path}/var/storage" )
86
- siteaccess_list.each{ |siteaccess_identifier|
87
- run( "mkdir -p #{shared_path}/var/#{siteaccess_identifier}/storage" )
249
+ puts( " OK".green )
250
+
251
+ storage_directories.each{ |sd|
252
+ print_dotted( "var/#{sd}/storage" )
253
+ run( "mkdir -p #{shared_path}/var/#{sd}/storage" )
254
+ puts( " OK".green )
255
+ }
256
+ run( "chmod -R g+w #{shared_path}/var")
257
+ run( "chown -R #{fetch(:webserver_group,:user)} #{shared_path}/var")
258
+ end
259
+
260
+
261
+
262
+ desc <<-DESC
263
+ [internal] Creates release directories
264
+ DESC
265
+ task :init_release, :roles => :web do
266
+ puts( "\n--> Release directories" )
267
+
268
+ # creates a storage dir for elements specified by :storage_directories
269
+ storage_directories.each{ |sd|
270
+ print_dotted( "var/#{sd}/storage" )
271
+ run( "mkdir #{latest_release}/var/#{sd}" )
272
+ puts( " OK".green )
88
273
  }
89
274
 
90
- fix_permissions( "#{shared_path}/var", webserver_user, webserver_group )
275
+ # makes sure the webserver can write into var/
276
+ run( "chmod -R g+w #{latest_release}/var")
277
+ run( "chown -R #{fetch(:webserver_user,:user)}:#{fetch(:webserver_group,:user)} #{latest_release}/var")
278
+ # needed even if we just want to run 'bin/php/ezpgenerateautoloads.php' with --extension
279
+ run( "chown -R #{fetch(:webserver_user,:user)}:#{fetch(:webserver_group,:user)} #{latest_release}/autoload")
91
280
  end
92
281
 
93
282
  desc <<-DESC
94
283
  Link .../shared/var into ../releases/[latest_release]/var
95
284
  DESC
96
285
  task :link, :roles => :web do
97
- run( "mkdir #{latest_release}/var" )
98
- siteaccess_list.each{ |siteaccess_identifier|
99
- run( "mkdir #{latest_release}/var/#{siteaccess_identifier}" )
100
- }
286
+ puts( "\n--> Symlinks" )
101
287
 
102
- fix_permissions( "#{latest_release}/var", webserver_user, webserver_group )
288
+ print_dotted( "var/storage" )
289
+ run( "ln -s #{shared_path}/var/storage #{latest_release}/var/storage" )
290
+ puts( " OK".green )
103
291
 
104
- try_sudo( "ln -s #{shared_path}/var/storage #{latest_release}/var/storage", :as => webserver_user )
105
- siteaccess_list.each{ |siteaccess_identifier|
106
- try_sudo( "ln -s #{shared_path}/var/#{siteaccess_identifier}/storage #{latest_release}/var/#{siteaccess_identifier}/storage", :as => webserver_user )
292
+ storage_directories.each{ |sd|
293
+ print_dotted( "var/#{sd}/storage" )
294
+ run( "ln -s #{shared_path}/var/#{sd}/storage #{latest_release}/var/#{sd}/storage", :as => webserver_user )
295
+ #run( "chmod -h g+w #{latest_release}/var/#{sd}/storage")
296
+ puts( " OK".green )
107
297
  }
298
+
299
+ run( "chmod -R g+w #{latest_release}/var")
300
+ run( "chown -R #{fetch(:webserver_user,:user)}:#{fetch(:webserver_group,:user)} #{shared_path}/var")
108
301
  end
109
302
 
110
303
  desc <<-DESC
@@ -159,12 +352,17 @@ namespace :capez do
159
352
  Generates autoloads (extensions and kernel overrides)
160
353
  DESC
161
354
  task :generate do
162
- on_rollback do
163
- generate
355
+ if autoload_list.count == 0
356
+ print_dotted( "--> eZ Publish autoloads (disabled)", :sol => true )
357
+ puts( " OK".green )
358
+ else
359
+ puts( "\n--> eZ Publish autoloads " )
360
+ autoload_list.each { |autoload|
361
+ print_dotted( "#{autoload}" )
362
+ capture( "cd #{latest_release} && sudo -u #{webserver_user} php bin/php/ezpgenerateautoloads.php --#{autoload}" )
363
+ puts( " OK".green )
364
+ }
164
365
  end
165
- autoload_list.each { |autoload|
166
- capture( "cd #{latest_release} && sudo -u #{webserver_user} php bin/php/ezpgenerateautoloads.php --#{autoload}" )
167
- }
168
366
  end
169
367
  end
170
368
  # End of namespace :capez:autoloads
@@ -172,7 +370,7 @@ namespace :capez do
172
370
  # Should be transformed in a simple function (not aimed to be called as a Cap task...)
173
371
  namespace :dev do
174
372
  desc <<-DESC
175
- Checks if there are local changes or not (only with Git)
373
+ Checks changes on your local installation
176
374
  Considers that your main git repo is at the top of your eZ Publish install
177
375
  If changes are detected, then ask the user to continue or not
178
376
  DESC
@@ -184,26 +382,26 @@ namespace :capez do
184
382
  ezroot_path = fetch( :ezpublish_path, false )
185
383
  abort "Please set a correct path to your eZ Publish root (:ezpublish_path) or add 'set :ezpublish_path, File.expand_path( File.dirname( __FILE__ ) )' in your Capfile" unless ezroot_path != false and File.exists?(ezroot_path)
186
384
 
385
+ puts( "\n--> Local installation check with git status" )
187
386
  git_status = git_status_result( ezroot_path )
188
387
 
189
388
  ask_to_abort = false
190
- puts "Checking your local git..."
191
389
  if git_status['has_local_changes']
192
390
  ask_to_abort = true
193
- puts "You have local changes"
391
+ puts( " - You have local changes" )
194
392
  end
195
393
  if git_status['has_new_files']
196
394
  ask_to_abort = true
197
- puts "You have new files"
395
+ puts( " - You have untracked files (not under git control)" )
198
396
  end
199
397
 
200
398
  if ask_to_abort
201
- user_abort = Capistrano::CLI.ui.ask "Abort ? y/n (n)"
202
- abort "Deployment aborted to commit/add local changes" unless user_abort == "n" or user_abort == ""
399
+ user_abort = Capistrano::CLI.ui.ask " Abort ? y/n (n)"
400
+ abort "Deployment aborted to commit/add local changes".red unless user_abort == "n" or user_abort == ""
203
401
  end
204
402
 
205
403
  if git_status['tracked_branch_status'] == 'ahead'
206
- puts "You have #{git_status['tracked_branch_commits']} commits that need to be pushed"
404
+ print " - You have #{git_status['tracked_branch_commits']} commits that need to be pushed"
207
405
  push_before = Capistrano::CLI.ui.ask "Push them before deployment ? y/n (y)"
208
406
  if push_before == "" or push_before == "y"
209
407
  system "git push"
@@ -248,4 +446,29 @@ namespace :capez do
248
446
  end
249
447
  end
250
448
 
449
+
450
+ end
451
+
452
+ def print_dotted( message, options={} )
453
+ defaults_options = { :eol => false,
454
+ :sol => false,
455
+ :max_length => 40,
456
+ :eol_msg => false }
457
+
458
+ options = defaults_options.merge( options )
459
+ message = "#{message} " + "." * [0,options[:max_length]-message.length-1].max
460
+
461
+ if options[:sol]
462
+ message = "\n#{message}"
463
+ end
464
+
465
+ if options[:eol_msg]
466
+ message += " #{options[:eol_msg]}"
467
+ end
468
+
469
+ if options[:eol]
470
+ puts message
471
+ else
472
+ print message
473
+ end
251
474
  end
@@ -0,0 +1,26 @@
1
+ # spinner stuff
2
+ # inspired from http://jondavidjohn.com/blog/2012/04/cleaning-up-capistrano-deployment-output
3
+ @spinner_running = false
4
+ @chars = ['|', '/', '-', '\\']
5
+ @spinner = Thread.new do
6
+ loop do
7
+ unless @spinner_running
8
+ Thread.stop
9
+ end
10
+ print @chars[0]
11
+ sleep(0.1)
12
+ print "\b"
13
+ @chars.push @chars.shift
14
+ end
15
+ end
16
+
17
+ def start_spinner
18
+ @spinner_running = true
19
+ @spinner.wakeup
20
+ end
21
+
22
+ # stops the spinner and backspaces over last displayed character
23
+ def stop_spinner
24
+ @spinner_running = false
25
+ print "\b"
26
+ end
metadata CHANGED
@@ -4,9 +4,9 @@ version: !ruby/object:Gem::Version
4
4
  prerelease: false
5
5
  segments:
6
6
  - 0
7
- - 2
7
+ - 3
8
8
  - 0
9
- version: 0.2.0
9
+ version: 0.3.0
10
10
  platform: ruby
11
11
  authors:
12
12
  - Arnaud Lafon
@@ -14,7 +14,7 @@ autorequire:
14
14
  bindir: bin
15
15
  cert_chain: []
16
16
 
17
- date: 2012-10-18 00:00:00 +02:00
17
+ date: 2012-11-29 00:00:00 +01:00
18
18
  default_executable:
19
19
  dependencies:
20
20
  - !ruby/object:Gem::Dependency
@@ -31,6 +31,19 @@ dependencies:
31
31
  version: 2.12.0
32
32
  type: :runtime
33
33
  version_requirements: *id001
34
+ - !ruby/object:Gem::Dependency
35
+ name: colored
36
+ prerelease: false
37
+ requirement: &id002 !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ segments:
42
+ - 1
43
+ - 2
44
+ version: "1.2"
45
+ type: :runtime
46
+ version_requirements: *id002
34
47
  description: Capistrano is a utility and framework for executing commands in parallel on multiple remote machines, via SSH. This package gives you some tools to deploy your eZ Publish projects.
35
48
  email: alcapon@arnaudlafon.com
36
49
  executables:
@@ -43,6 +56,7 @@ files:
43
56
  - bin/capezit
44
57
  - lib/capez.rb
45
58
  - lib/db.rb
59
+ - lib/ext/spinner.rb
46
60
  - README.md
47
61
  - LICENSE.md
48
62
  has_rdoc: true