sunshine 1.2.0 → 1.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -7,13 +7,23 @@ module Sunshine
7
7
 
8
8
  include Open4
9
9
 
10
- class TimeoutError < CriticalDeployError; end
11
-
12
10
  LOCAL_USER = `whoami`.chomp
13
11
  LOCAL_HOST = `hostname`.chomp
14
12
 
15
- SUDO_FAILED = /^Sorry, try again./
16
- SUDO_PROMPT = /^Password:/
13
+ class << self
14
+ # The message to match in stderr to determine logging in has failed.
15
+ # Defaults to:
16
+ # /^Sorry, try again./
17
+ attr_accessor :sudo_failed_matcher
18
+
19
+ # The message to match in stderr to determine a password is required.
20
+ # Defaults to:
21
+ # /^Password:/
22
+ attr_accessor :sudo_prompt_matcher
23
+ end
24
+
25
+ self.sudo_failed_matcher = /^Sorry, try again./
26
+ self.sudo_prompt_matcher = /^Password:/
17
27
 
18
28
  attr_reader :user, :host, :password, :input, :output, :mutex
19
29
  attr_accessor :env, :sudo, :timeout
@@ -33,8 +43,6 @@ module Sunshine
33
43
 
34
44
  @timeout = options[:timeout] || Sunshine.timeout
35
45
 
36
- @cmd_activity = nil
37
-
38
46
  @mutex = nil
39
47
  end
40
48
 
@@ -73,6 +81,14 @@ module Sunshine
73
81
  end
74
82
 
75
83
 
84
+ ##
85
+ # Prompt the user to make a choice.
86
+
87
+ def choose &block
88
+ sync{ @input.choose(&block) }
89
+ end
90
+
91
+
76
92
  ##
77
93
  # Close the output IO. (Required by the Logger class)
78
94
 
@@ -133,6 +149,21 @@ module Sunshine
133
149
  end
134
150
 
135
151
 
152
+ ##
153
+ # Start an interactive shell with preset permissions and env.
154
+ # Optionally pass a command to be run first.
155
+
156
+ def tty! cmd=nil
157
+ sync do
158
+ cmd = [cmd, "sh -il"].compact.join " && "
159
+ pid = fork do
160
+ exec sudo_cmd(env_cmd(cmd)).to_a.join(" ")
161
+ end
162
+ Process.waitpid pid
163
+ end
164
+ end
165
+
166
+
136
167
  ##
137
168
  # Write a file. Compatibility method with RemoteShell.
138
169
 
@@ -237,7 +268,7 @@ module Sunshine
237
268
  ##
238
269
  # Returns true if command was run successfully, otherwise returns false.
239
270
 
240
- def syscall cmd, options=nil
271
+ def system cmd, options=nil
241
272
  call(cmd, options) && true rescue false
242
273
  end
243
274
 
@@ -245,20 +276,12 @@ module Sunshine
245
276
  ##
246
277
  # Checks if timeout occurred.
247
278
 
248
- def timed_out? start_time=@cmd_activity, max_time=@timeout
279
+ def timed_out? start_time, max_time=@timeout
249
280
  return unless max_time
250
281
  Time.now.to_i - start_time.to_i > max_time
251
282
  end
252
283
 
253
284
 
254
- ##
255
- # Update the time of the last command activity
256
-
257
- def update_timeout
258
- @cmd_activity = Time.now
259
- end
260
-
261
-
262
285
  ##
263
286
  # Execute a block while setting the shell's mutex.
264
287
  # Sets the mutex to its original value on exit.
@@ -319,7 +342,9 @@ module Sunshine
319
342
  log_methods = {out => :debug, err => :error}
320
343
 
321
344
  result, status = process_streams(pid, out, err) do |stream, data|
322
- stream_name = stream == out ? :out : :err
345
+ stream_name = :out if stream == out
346
+ stream_name = :err if stream == err
347
+ stream_name = :inn if stream == inn
323
348
 
324
349
 
325
350
  # User blocks should run with sync threads to avoid badness.
@@ -353,18 +378,18 @@ module Sunshine
353
378
  private
354
379
 
355
380
  def raise_command_failed(status, cmd)
356
- raise CmdError,
357
- "Execution failed with status #{status.exitstatus}: #{[*cmd].join ' '}"
381
+ err = CmdError.new status.exitstatus, [*cmd].join(" ")
382
+ raise err
358
383
  end
359
384
 
360
385
 
361
386
  def password_required? stream_name, data
362
- stream_name == :err && data =~ SUDO_PROMPT
387
+ stream_name == :err && data =~ Shell.sudo_prompt_matcher
363
388
  end
364
389
 
365
390
 
366
391
  def send_password_to_stream inn, data
367
- prompt_for_password if data =~ SUDO_FAILED
392
+ prompt_for_password if data =~ Shell.sudo_failed_matcher
368
393
  inn.puts @password || prompt_for_password
369
394
  end
370
395
 
@@ -380,7 +405,7 @@ module Sunshine
380
405
 
381
406
  def process_streams pid, *streams
382
407
  result = Hash.new{|h,k| h[k] = []}
383
- update_timeout
408
+ start_time = Time.now
384
409
 
385
410
  # Handle process termination ourselves
386
411
  status = nil
@@ -392,13 +417,13 @@ module Sunshine
392
417
  # don't busy loop
393
418
  selected, = select streams, nil, nil, 0.1
394
419
 
395
- raise TimeoutError if timed_out?
420
+ raise TimeoutError if timed_out? start_time
396
421
 
397
422
  next if selected.nil? or selected.empty?
398
423
 
399
424
  selected.each do |stream|
400
425
 
401
- update_timeout
426
+ start_time = Time.now
402
427
 
403
428
  if stream.eof? then
404
429
  streams.delete stream if status # we've quit, so no more writing
@@ -0,0 +1,54 @@
1
+ module Sunshine
2
+
3
+ ##
4
+ # The TrapStack class handles setting multiple trap blocks as a stack.
5
+ # Once a trap block is triggered, it gets popped off the stack.
6
+
7
+ class TrapStack
8
+
9
+ ##
10
+ # Adds an INT signal trap with its description on the stack.
11
+ # Returns a trap_item Array.
12
+
13
+ def self.add_trap desc=nil, &block
14
+ @trap_stack.unshift [desc, block]
15
+ @trap_stack.first
16
+ end
17
+
18
+
19
+
20
+ ##
21
+ # Call a trap item and display it's message.
22
+
23
+ def self.call_trap trap_item
24
+ return unless trap_item
25
+
26
+ msg, trap_block = trap_item
27
+
28
+ yield msg if block_given?
29
+
30
+ trap_block.call
31
+ end
32
+
33
+
34
+ ##
35
+ # Remove a trap_item from the stack.
36
+
37
+ def self.delete_trap trap_item
38
+ @trap_stack.delete trap_item
39
+ end
40
+
41
+
42
+ ##
43
+ # Sets the default trap.
44
+
45
+ def self.trap_signal sig, &block
46
+ @trap_stack = []
47
+
48
+ trap sig do
49
+ call_trap @trap_stack.shift, &block
50
+ exit 1
51
+ end
52
+ end
53
+ end
54
+ end
@@ -25,7 +25,31 @@ http {
25
25
  client_body_temp_path <%= darwin ? '/var/tmp/nginx' : '/dev/shm' %>;
26
26
  proxy_temp_path <%= darwin ? '/var/tmp/nginx' : '/dev/shm' %>;
27
27
 
28
+ <%
29
+ mime_types = "#{File.dirname(shell.call("which nginx"))}/../conf/mime.types"
30
+
31
+ if shell.file? mime_types
32
+ -%>
28
33
  include <%= File.dirname shell.call("which nginx") %>/../conf/mime.types;
34
+
35
+ <% else -%>
36
+
37
+ types {
38
+ application/x-plist plist;
39
+ application/json json;
40
+ image/gif gif;
41
+ image/jpeg jpg;
42
+ image/png png;
43
+ image/x-icon ico;
44
+ text/css css;
45
+ text/html html;
46
+ text/plain bob;
47
+ text/plain txt;
48
+ application/vnd.android.package-archive apk;
49
+ }
50
+
51
+ <% end -%>
52
+
29
53
  default_type application/octet-stream;
30
54
 
31
55
  log_format sunshine '$remote_addr - $remote_user [$time_local] '
@@ -123,7 +123,7 @@ fi
123
123
  "--rsync-path='#{ path }' "
124
124
  end
125
125
 
126
- rsync_cmd = "rsync -azP #{rsync_path}-e \"ssh #{ds.ssh_flags.join(' ')}\""
126
+ rsync_cmd = "rsync -azrP #{rsync_path}-e \"ssh #{ds.ssh_flags.join(' ')}\""
127
127
 
128
128
  error_msg = "No such command in remote_shell log [#{ds.host}]\n#{rsync_cmd}"
129
129
  error_msg << "#{from.inspect} #{to.inspect}"
@@ -26,6 +26,7 @@ class TestApp < Test::Unit::TestCase
26
26
  end
27
27
 
28
28
  def teardown
29
+ Sunshine.exclude_paths.clear
29
30
  FileUtils.rm_rf @tmpdir
30
31
  end
31
32
 
@@ -159,8 +160,7 @@ class TestApp < Test::Unit::TestCase
159
160
 
160
161
  def test_app_deploy_error_handling
161
162
  [ MockError,
162
- Sunshine::CriticalDeployError,
163
- Sunshine::FatalDeployError ].each do |error|
163
+ Sunshine::DeployError ].each do |error|
164
164
 
165
165
  begin
166
166
  app = Sunshine::App.deploy @config do |app|
@@ -277,9 +277,51 @@ class TestApp < Test::Unit::TestCase
277
277
  end
278
278
 
279
279
 
280
+ def test_checkout_local_codebase
281
+ tmp_path = File.join Sunshine::TMP_DIR, "#{@app.name}_checkout"
282
+ @app.repo.extend MockObject
283
+ @app.repo.mock :checkout_to, :args => [tmp_path],
284
+ :return => {:test => "scm info"}
285
+
286
+ @app.each do |sa|
287
+ sa.mock :upload_codebase
288
+ end
289
+
290
+ @app.checkout_codebase :copy => true
291
+
292
+ @app.each do |sa|
293
+ assert sa.method_called?(:upload_codebase,
294
+ :args => [tmp_path, {:test => "scm info", :exclude => []}])
295
+ end
296
+ end
297
+
298
+
299
+ def test_checkout_local_codebase_with_exludes
300
+ Sunshine.exclude_paths.concat ["path1/", "path2/"]
301
+
302
+ tmp_path = File.join Sunshine::TMP_DIR, "#{@app.name}_checkout"
303
+ @app.repo.extend MockObject
304
+
305
+ @app.repo.mock :checkout_to, :args => [tmp_path],
306
+ :return => {:test => "scm info"}
307
+
308
+ @app.each do |sa|
309
+ sa.mock :upload_codebase
310
+ end
311
+
312
+ @app.checkout_codebase :copy => true, :exclude => ["path3/", "path4/"]
313
+
314
+ @app.each do |sa|
315
+ assert sa.method_called?(:upload_codebase,
316
+ :args => [tmp_path, {:test => "scm info",
317
+ :exclude => ["path1/", "path2/", "path3/", "path4/"]}])
318
+ end
319
+ end
320
+
321
+
280
322
  def test_deployed?
281
323
  set_mock_response_for @app, 0,
282
- "cat #{@app.scripts_path}/info" => [:out,
324
+ "cat #{@app.root_path}/info" => [:out,
283
325
  "---\n:deploy_name: '#{@app.deploy_name}'"]
284
326
 
285
327
  deployed = @app.deployed?
@@ -437,7 +479,7 @@ class TestApp < Test::Unit::TestCase
437
479
  returned_dirs = %w{old_deploy1 old_deploy2 old_deploy3 main_deploy}
438
480
  old_deploys = returned_dirs[0..-2].map{|d| "#{@app.deploys_path}/#{d}"}
439
481
 
440
- list_cmd = "ls -1 #{@app.deploys_path}"
482
+ list_cmd = "ls -rc1 #{@app.deploys_path}"
441
483
  rm_cmd = "rm -rf #{old_deploys.join(" ")}"
442
484
 
443
485
  set_mock_response_for @app, 0,
@@ -500,7 +542,7 @@ class TestApp < Test::Unit::TestCase
500
542
 
501
543
  @app.threaded_each do |server_app|
502
544
  if server_app.shell.host == err_host
503
- raise Sunshine::CriticalDeployError, server_app.shell.host
545
+ raise Sunshine::DeployError, server_app.shell.host
504
546
  else
505
547
  finished = finished.next
506
548
  end
@@ -508,7 +550,7 @@ class TestApp < Test::Unit::TestCase
508
550
 
509
551
  raise "Didn't raise threaded error when it should have"
510
552
 
511
- rescue Sunshine::CriticalDeployError => e
553
+ rescue Sunshine::DeployError => e
512
554
  host = @app.server_apps.first.shell.host
513
555
 
514
556
  assert_equal host, e.message
@@ -17,9 +17,9 @@ class TestDaemon < Test::Unit::TestCase
17
17
 
18
18
  begin
19
19
  daemon.start_cmd
20
- raise "Should have thrown CriticalDeployError but didn't :("
21
- rescue Sunshine::CriticalDeployError => e
22
- assert_equal "@start_cmd undefined. Can't start daemon", e.message
20
+ raise "Should have thrown DaemonError but didn't :("
21
+ rescue Sunshine::DaemonError => e
22
+ assert_equal "start_cmd undefined for daemon", e.message
23
23
  end
24
24
  end
25
25
 
@@ -87,13 +87,16 @@ class TestNginx < Test::Unit::TestCase
87
87
  ## Helper methods
88
88
 
89
89
  def start_cmd svr
90
- "#{svr.bin} -c #{svr.config_file_path}"
90
+ svr.exit_on_failure "#{svr.bin} -c #{svr.config_file_path}", 10,
91
+ "Could not start #{svr.name} for #{svr.app.name}"
91
92
  end
92
93
 
93
94
 
94
95
  def stop_cmd svr
95
- "test -f #{svr.pid} && kill -#{svr.sigkill} $(cat #{svr.pid}) && "+
96
- "sleep 1 && rm -f #{svr.pid} || "+
97
- "echo 'Could not kill #{svr.name} pid for #{svr.app.name}';"
96
+ cmd = "test -f #{svr.pid} && kill -#{svr.sigkill} $(cat #{svr.pid}) && "+
97
+ "sleep 1 && rm -f #{svr.pid}"
98
+
99
+ svr.exit_on_failure cmd, 11,
100
+ "Could not kill #{svr.name} pid for #{svr.app.name}"
98
101
  end
99
102
  end
@@ -69,7 +69,7 @@ class TestServer < Test::Unit::TestCase
69
69
  assert sa.method_called?(:install_deps, :args => ["rainbows"])
70
70
 
71
71
  assert server.method_called?(:configure_remote_dirs, :args => [sa.shell])
72
- assert server.method_called?(:touch_log_files, :args => [sa.shell])
72
+ assert server.method_called?(:chown_log_files, :args => [sa.shell])
73
73
  assert server.method_called?(:upload_config_files, :args => [sa.shell])
74
74
  end
75
75
 
@@ -121,7 +121,7 @@ class TestServer < Test::Unit::TestCase
121
121
 
122
122
  server.start do |sa|
123
123
  assert_equal @server_app, sa
124
- assert_ssh_call server.start_cmd, sa.shell, :sudo => true
124
+ assert_ssh_call server._start_cmd, sa.shell, :sudo => true
125
125
  end
126
126
 
127
127
  assert server.method_called?(:setup)
@@ -135,7 +135,7 @@ class TestServer < Test::Unit::TestCase
135
135
 
136
136
  server.start do |sa|
137
137
  assert_equal @server_app, sa
138
- assert_ssh_call server.start_cmd, sa.shell, :sudo => true
138
+ assert_ssh_call server._start_cmd, sa.shell, :sudo => true
139
139
  end
140
140
 
141
141
  assert !server.method_called?(:setup)
@@ -147,7 +147,7 @@ class TestServer < Test::Unit::TestCase
147
147
 
148
148
  server.stop do |sa|
149
149
  assert_equal @server_app, sa
150
- assert_ssh_call server.stop_cmd, sa.shell, :sudo => true
150
+ assert_ssh_call server._stop_cmd, sa.shell, :sudo => true
151
151
  end
152
152
  end
153
153
 
@@ -157,7 +157,7 @@ class TestServer < Test::Unit::TestCase
157
157
 
158
158
  server.restart do |sa|
159
159
  assert_equal @server_app, sa
160
- assert_ssh_call server.restart_cmd, sa.shell, :sudo => true
160
+ assert_ssh_call server._restart_cmd, sa.shell, :sudo => true
161
161
  end
162
162
  end
163
163
 
@@ -181,7 +181,7 @@ class TestServer < Test::Unit::TestCase
181
181
 
182
182
  server.restart do |sa|
183
183
  assert_equal @server_app, sa
184
- assert_ssh_call server.restart_cmd, sa.shell, :sudo => true
184
+ assert_ssh_call server._restart_cmd, sa.shell, :sudo => true
185
185
  end
186
186
 
187
187
  assert server.method_called?(:setup)
@@ -15,6 +15,27 @@ class TestServerApp < Test::Unit::TestCase
15
15
  end
16
16
 
17
17
 
18
+ def test_from_info_file
19
+ info_data = @sa.get_deploy_info.to_yaml
20
+ @sa.shell.mock :call, :args => "cat info/path", :return => info_data
21
+
22
+ server_app = Sunshine::ServerApp.from_info_file "info/path", @sa.shell
23
+
24
+ assert_equal @sa.root_path, server_app.root_path
25
+ assert_equal @sa.checkout_path, server_app.checkout_path
26
+ assert_equal @sa.current_path, server_app.current_path
27
+ assert_equal @sa.deploys_path, server_app.deploys_path
28
+ assert_equal @sa.log_path, server_app.log_path
29
+ assert_equal @sa.shared_path, server_app.shared_path
30
+ assert_equal @sa.scripts_path, server_app.scripts_path
31
+ assert_equal @sa.roles, server_app.roles
32
+
33
+ assert_equal @sa.shell.env, server_app.shell_env
34
+ assert_equal @sa.name, server_app.name
35
+ assert_equal @sa.deploy_name, server_app.deploy_name
36
+ end
37
+
38
+
18
39
  def test_init
19
40
  default_info = {:ports => {}}
20
41
  assert_equal default_info, @sa.info
@@ -119,7 +140,7 @@ class TestServerApp < Test::Unit::TestCase
119
140
  deploy_details = {:item => "thing"}
120
141
  other_details = {:key => "value"}
121
142
 
122
- @sa.shell.mock :call, :args => ["cat #{@sa.scripts_path}/info"],
143
+ @sa.shell.mock :call, :args => ["cat #{@sa.root_path}/info"],
123
144
  :return => other_details.to_yaml
124
145
 
125
146
  @sa.instance_variable_set "@deploy_details", deploy_details
@@ -158,8 +179,11 @@ class TestServerApp < Test::Unit::TestCase
158
179
  :deployed_as => @sa.shell.call("whoami"),
159
180
  :deployed_by => Sunshine.shell.user,
160
181
  :deploy_name => File.basename(@app.checkout_path),
182
+ :name => @sa.name,
183
+ :env => @sa.shell_env,
161
184
  :roles => @sa.roles,
162
- :path => @app.root_path
185
+ :path => @app.root_path,
186
+ :sunshine_version => Sunshine::VERSION
163
187
  }.merge @sa.info
164
188
 
165
189
  deploy_info = @sa.get_deploy_info
@@ -204,7 +228,7 @@ class TestServerApp < Test::Unit::TestCase
204
228
  @sa.install_deps nginx_dep, :type => Sunshine::Gem
205
229
  raise "Didn't raise missing dependency when it should have."
206
230
 
207
- rescue Sunshine::DependencyLib::MissingDependency => e
231
+ rescue Sunshine::MissingDependency => e
208
232
  assert_equal "No dependency 'nginx' [Sunshine::Gem]", e.message
209
233
  end
210
234
 
@@ -257,7 +281,7 @@ class TestServerApp < Test::Unit::TestCase
257
281
  deploys = %w{ploy1 ploy2 ploy3 ploy4 ploy5}
258
282
 
259
283
  @sa.shell.mock :call,
260
- :args => ["ls -1 #{@app.deploys_path}"],
284
+ :args => ["ls -rc1 #{@app.deploys_path}"],
261
285
  :return => "#{deploys.join("\n")}\n"
262
286
 
263
287
  removed = deploys[0..1].map{|d| "#{@app.deploys_path}/#{d}"}
@@ -330,6 +354,32 @@ class TestServerApp < Test::Unit::TestCase
330
354
  end
331
355
 
332
356
 
357
+ def test_running?
358
+ set_mock_response_for @sa, 0,
359
+ "#{@sa.root_path}/status" => [:out, "THE SYSTEM OK!"]
360
+
361
+ assert_equal true, @sa.running?
362
+ end
363
+
364
+
365
+ def test_not_running?
366
+ set_mock_response_for @sa, 13,
367
+ "#{@sa.root_path}/status" => [:err, "THE SYSTEM IS DOWN!"]
368
+
369
+ assert_equal false, @sa.running?
370
+ end
371
+
372
+
373
+ def test_errored_running?
374
+ set_mock_response_for @sa, 1,
375
+ "#{@sa.root_path}/status" => [:err, "KABLAM!"]
376
+
377
+ assert_raises Sunshine::CmdError do
378
+ @sa.running?
379
+ end
380
+ end
381
+
382
+
333
383
  def test_sass
334
384
  sass_files = %w{file1 file2 file3}
335
385
 
@@ -426,6 +476,50 @@ class TestServerApp < Test::Unit::TestCase
426
476
  end
427
477
 
428
478
 
479
+ def test_upload_codebase
480
+ @sa.shell.mock(:upload)
481
+
482
+ @sa.upload_codebase "tmp/thing/", :test_scm => "info"
483
+ assert_equal({:test_scm => "info"} , @sa.info[:scm])
484
+ assert @sa.shell.method_called?(
485
+ :upload, :args => ["tmp/thing/", @sa.checkout_path,
486
+ {:flags => ["--exclude .svn/","--exclude .git/"]}])
487
+ end
488
+
489
+
490
+ def test_upload_codebase_with_exclude
491
+ @sa.shell.mock(:upload)
492
+
493
+ @sa.upload_codebase "tmp/thing/",
494
+ :test_scm => "info",
495
+ :exclude => "bad/path/"
496
+
497
+ assert_equal({:test_scm => "info"} , @sa.info[:scm])
498
+ assert @sa.shell.method_called?(
499
+ :upload, :args => ["tmp/thing/", @sa.checkout_path,
500
+ {:flags => ["--exclude bad/path/",
501
+ "--exclude .svn/",
502
+ "--exclude .git/"]}])
503
+ end
504
+
505
+
506
+ def test_upload_codebase_with_excludes
507
+ @sa.shell.mock(:upload)
508
+
509
+ @sa.upload_codebase "tmp/thing/",
510
+ :test_scm => "info",
511
+ :exclude => ["bad/path/", "other/exclude/"]
512
+
513
+ assert_equal({:test_scm => "info"} , @sa.info[:scm])
514
+ assert @sa.shell.method_called?(
515
+ :upload, :args => ["tmp/thing/", @sa.checkout_path,
516
+ {:flags => ["--exclude bad/path/",
517
+ "--exclude other/exclude/",
518
+ "--exclude .svn/",
519
+ "--exclude .git/"]}])
520
+ end
521
+
522
+
429
523
  def test_write_script
430
524
  @sa.shell.mock :file?, :return => false
431
525
 
@@ -435,10 +529,19 @@ class TestServerApp < Test::Unit::TestCase
435
529
  "script contents", {:flags => "--chmod=ugo=rwx"}]
436
530
 
437
531
  assert @sa.shell.method_called?(:make_file, :args => args)
532
+ end
533
+
534
+
535
+ def test_symlink_scripts_to_root
536
+ @sa.shell.mock :call,
537
+ :args => ["ls -1 #{@sa.scripts_path}"],
538
+ :return => "script_name\n"
438
539
 
439
540
  args = ["#{@app.scripts_path}/script_name",
440
541
  "#{@app.root_path}/script_name"]
441
542
 
543
+ @sa.symlink_scripts_to_root
544
+
442
545
  assert @sa.shell.method_called?(:symlink, :args => args)
443
546
  end
444
547
 
@@ -452,10 +555,5 @@ class TestServerApp < Test::Unit::TestCase
452
555
  "script contents", {:flags => "--chmod=ugo=rwx"}]
453
556
 
454
557
  assert !@sa.shell.method_called?(:make_file, :args => args)
455
-
456
- args = ["#{@app.scripts_path}/script_name",
457
- "#{@app.root_path}/script_name"]
458
-
459
- assert @sa.shell.method_called?(:symlink, :args => args)
460
558
  end
461
559
  end