symphony 0.3.0.pre20140327204419

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'shellwords'
4
+ require 'symphony/task' unless defined?( Symphony::Task )
5
+
6
+
7
+ ### A base SSH class for connecting to remote hosts, running commands,
8
+ ### and collecting output.
9
+ class Symphony::Task::SSH < Symphony::Task
10
+ extend MethodUtilities
11
+
12
+ ### Create a new SSH task for the given +job+ and +queue+.
13
+ def initialize( queue, job )
14
+ super
15
+
16
+ # The default path to the ssh binary.
17
+ @path = self.options[:ssh_path] || '/usr/bin/ssh'
18
+
19
+ # Default ssh behavior arguments.
20
+ @ssh_args = self.options[:ssh_args] || [
21
+ '-e', 'none',
22
+ '-T',
23
+ '-x',
24
+ '-q',
25
+ '-o', 'CheckHostIP=no',
26
+ '-o', 'BatchMode=yes',
27
+ '-o', 'StrictHostKeyChecking=no'
28
+ ]
29
+
30
+ # required arguments
31
+ @hostname = self.options[:hostname] or raise ArgumentError, "no hostname specified"
32
+ @command = self.options[:command] or raise ArgumentError, "no command specified"
33
+
34
+ # optional arguments
35
+ @port = self.options[:port] || 22
36
+ @user = self.options[:user] || 'root'
37
+ @key = self.options[:key]
38
+
39
+ @output = nil
40
+ @return_value = nil
41
+ end
42
+
43
+ # The default path to the ssh binary.
44
+ attr_reader :path
45
+
46
+ # Default ssh behavior arguments.
47
+ attr_reader :ssh_args
48
+
49
+ # The hostname to connect to.
50
+ attr_reader :hostname
51
+
52
+ # The command to run on the remote host.
53
+ attr_reader :command
54
+
55
+ # The key to use for authentication.
56
+ attr_reader :key
57
+
58
+ # The remote ssh port.
59
+ attr_reader :port
60
+
61
+ # Connect to the remote host as this user. Defaults to 'root'.
62
+ attr_reader :user
63
+
64
+
65
+ ### Call ssh and capture output.
66
+ def run
67
+ @return_value = self.open_connection do |reader, writer|
68
+ self.log.debug "Writing command #{self.command}..."
69
+ writer.puts( self.command )
70
+ self.log.debug " closing child's writer."
71
+ writer.close
72
+ self.log.debug " reading from child."
73
+ reader.read
74
+ end
75
+ end
76
+
77
+
78
+ ### Emit the output from the remote ssh call
79
+ def on_completion
80
+ if @return_value
81
+ self.log.info "Remote exited with %d, output: %s" % [ @return_value.exitstatus, @output ]
82
+ end
83
+ end
84
+
85
+
86
+ #########
87
+ protected
88
+ #########
89
+
90
+ ### Call ssh and yield the remote IO objects to the caller,
91
+ ### cleaning up afterwards.
92
+ def open_connection
93
+ raise LocalJumpError, "no block given" unless block_given?
94
+
95
+ fqdn = self.expand_hostname( self.hostname ).
96
+ find {|hostname| self.ping(hostname, self.port) } or
97
+ raise "Unable to find an on-network host for %s:%d" % [ self.hostname, self.port ]
98
+
99
+ cmd = []
100
+ cmd << self.path
101
+ cmd += self.ssh_args
102
+ cmd << '-p' << self.port.to_s
103
+ cmd << '-i' << self.key if self.key
104
+ cmd << '-l' << self.user
105
+ cmd << fqdn
106
+ cmd.flatten!
107
+ self.log.debug "Running SSH command with: %p" % [ Shellwords.shelljoin(cmd) ]
108
+
109
+ parent_reader, child_writer = IO.pipe
110
+ child_reader, parent_writer = IO.pipe
111
+
112
+ pid = spawn( *cmd, :out => child_writer, :in => child_reader, :close_others => true )
113
+ child_writer.close
114
+ child_reader.close
115
+
116
+ self.log.debug "Yielding back to the run block."
117
+ @output = yield( parent_reader, parent_writer )
118
+ self.log.debug " run block done."
119
+
120
+ pid, status = Process.waitpid2( pid )
121
+ return status
122
+ end
123
+
124
+
125
+ end # class Symphony::Task::SSH
126
+
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'net/ssh'
4
+ require 'net/sftp'
5
+ require 'tmpdir'
6
+ require 'inversion'
7
+ require 'symphony/task' unless defined?( Symphony::Task )
8
+
9
+
10
+ # A task to execute a script on a remote host via SSH.
11
+ class Symphony::Task::SSHScript < Symphony::Task
12
+ extend Loggability,
13
+ MethodUtilities
14
+
15
+ # Loggability API -- Log to symphony's logger
16
+ log_to :symphony
17
+
18
+
19
+ # Template config
20
+ TEMPLATE_OPTS = {
21
+ :ignore_unknown_tags => false,
22
+ :on_render_error => :propagate,
23
+ :strip_tag_lines => true
24
+ }
25
+
26
+ # The defaults to use when connecting via SSH
27
+ DEFAULT_SSH_OPTIONS = {
28
+ :auth_methods => [ "publickey" ],
29
+ :compression => true,
30
+ :config => false,
31
+ :keys_only => true,
32
+ # :logger => Loggability[ Net::SSH ],
33
+ :paranoid => false,
34
+ :timeout => 10.seconds,
35
+ # :verbose => :debug,
36
+ :global_known_hosts_file => '/dev/null',
37
+ :user_known_hosts_file => '/dev/null',
38
+ }
39
+
40
+
41
+ ### Create a new SSH task for the given +job+ and +queue+.
42
+ def initialize( queue, job )
43
+ super
44
+
45
+ # required arguments
46
+ @hostname = self.options[:hostname] or raise ArgumentError, "no hostname specified"
47
+ @template = self.options[:template] or raise ArgumentError, "no script template specified"
48
+ @key = self.options[:key] or raise ArgumentError, "no private key specified"
49
+
50
+ # optional arguments
51
+ @port = self.options[:port] || 22
52
+ @user = self.options[:user] || 'root'
53
+ @attributes = self.options[:attributes] || {}
54
+ @nocleanup = self.options[:nocleanup] ? true : false
55
+ end
56
+
57
+
58
+ ######
59
+ public
60
+ ######
61
+
62
+ # The name of the host to connect to
63
+ attr_reader :hostname
64
+
65
+ # The path to the script template
66
+ attr_accessor :template
67
+
68
+ # The path to the SSH key to use for auth
69
+ attr_reader :key
70
+
71
+ # The SSH port to use
72
+ attr_reader :port
73
+
74
+ # The user to connect as
75
+ attr_reader :user
76
+
77
+ # Attributes that will be set on the script template.
78
+ attr_reader :attributes
79
+
80
+ # Flag that will cause the uploaded script to not be cleaned up after running. Useful for
81
+ # diagnostics.
82
+ attr_reader :nocleanup
83
+
84
+
85
+ ### Load the script as an Inversion template, sending and executing
86
+ ### it on the remote host.
87
+ def run
88
+ fqdn = self.expand_hostname( self.hostname ).
89
+ find {|hostname| self.ping(hostname, self.port) }
90
+
91
+ unless fqdn
92
+ self.log.debug "Unable to find an on-network host for %s:%d" %
93
+ [ self.hostname, self.port ]
94
+ return
95
+ end
96
+
97
+ remote_filename = self.make_remote_filename
98
+ source = self.generate_script
99
+
100
+ # Establish the SSH connection
101
+ ssh_options = DEFAULT_SSH_OPTIONS.merge( :port => self.port, :keys => [self.key] )
102
+ self.with_timeout do
103
+ Net::SSH.start( fqdn, self.user, ssh_options ) do |conn|
104
+ self.upload_script( conn, source, remote_filename )
105
+ self.run_script( conn, remote_filename )
106
+ end
107
+ end
108
+ end
109
+
110
+
111
+ #########
112
+ protected
113
+ #########
114
+
115
+ ### Return a human-readable description of details of the task.
116
+ def description
117
+ return "Running script '%s' on '%s:%d' as '%s'" % [
118
+ File.basename( self.template ),
119
+ self.hostname,
120
+ self.port,
121
+ self.user,
122
+ ]
123
+ end
124
+
125
+
126
+ ### Generate a unique filename for the script on the remote host.
127
+ def make_remote_filename
128
+ template = self.template
129
+ basename = File.basename( template, File.extname(template) )
130
+
131
+ tmpname = Dir::Tmpname.make_tmpname( basename, Process.pid )
132
+
133
+ return "/tmp/#{tmpname}"
134
+ end
135
+
136
+
137
+ ### Generate a script by loading the script template, populating it with
138
+ ### attributes, and rendering it.
139
+ def generate_script
140
+ tmpl = Inversion::Template.load( self.template, TEMPLATE_OPTS )
141
+
142
+ tmpl.attributes.merge!( self.attributes )
143
+ tmpl.task = self
144
+
145
+ return tmpl.render
146
+ end
147
+
148
+ ### Render the given +template+ as script source, then use the specified +conn+ object
149
+ ### to upload it.
150
+ def upload_script( conn, source, remote_filename )
151
+ self.log.debug "Uploading script (%d bytes) to %s:%s." %
152
+ [ source.bytesize, self.hostname, remote_filename ]
153
+ conn.sftp.file.open( remote_filename, "w", 0755 ) do |fh|
154
+ fh.print( source )
155
+ end
156
+ self.log.debug " done with the upload."
157
+ end
158
+
159
+
160
+ ### Run the script on the remote host.
161
+ def run_script( conn, remote_filename )
162
+ output = conn.exec!( remote_filename )
163
+ self.log.debug "Output was:\n#{output}"
164
+ conn.exec!( "rm #{remote_filename}" ) unless self.nocleanup
165
+ end
166
+
167
+ end # class Symphony::Task::SSHScript
168
+
data/lib/symphony.rb ADDED
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'loggability'
4
+ require 'configurability'
5
+
6
+ # Symphony -- an evented asynchronous thingie.
7
+ #
8
+ # See the README for additional guidance.
9
+ #
10
+ module Symphony
11
+ extend Loggability,
12
+ Configurability
13
+
14
+ # Library version constant
15
+ VERSION = '0.3.0'
16
+
17
+ # Version-control revision constant
18
+ REVISION = %q$Revision$
19
+
20
+
21
+ # The name of the environment variable to check for config file overrides
22
+ CONFIG_ENV = 'GROUNDCONTROL_CONFIG'
23
+
24
+ # The path to the default config file
25
+ DEFAULT_CONFIG_FILE = 'etc/config.yml'
26
+
27
+
28
+ # Loggability API -- set up symphony's logger
29
+ log_as :symphony
30
+
31
+
32
+ ### Get the loaded config (a Configurability::Config object)
33
+ def self::config
34
+ Configurability.loaded_config
35
+ end
36
+
37
+
38
+ ### Load the specified +config_file+, install the config in all objects with
39
+ ### Configurability, and call any callbacks registered via #after_configure.
40
+ def self::load_config( config_file=nil, defaults=nil )
41
+ config_file ||= ENV[ CONFIG_ENV ] || DEFAULT_CONFIG_FILE
42
+ defaults ||= Configurability.gather_defaults
43
+
44
+ self.log.info "Loading config from %p with defaults for sections: %p." %
45
+ [ config_file, defaults.keys ]
46
+ config = Configurability::Config.load( config_file, defaults )
47
+ config.install
48
+ end
49
+
50
+
51
+ require 'symphony/mixins'
52
+ require 'symphony/queue'
53
+ require 'symphony/task'
54
+
55
+ end # module Symphony
56
+
data/spec/helpers.rb ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/ruby
2
+ # coding: utf-8
3
+
4
+ # SimpleCov test coverage reporting; enable this using the :coverage rake task
5
+ require 'simplecov' if ENV['COVERAGE']
6
+
7
+ require 'loggability'
8
+ require 'loggability/spechelpers'
9
+ require 'configurability'
10
+ require 'configurability/behavior'
11
+
12
+ require 'rspec'
13
+
14
+ Loggability.format_with( :color ) if $stdout.tty?
15
+
16
+
17
+ ### RSpec helper functions.
18
+ module Loggability::SpecHelpers
19
+ end
20
+
21
+
22
+ ### Mock with RSpec
23
+ RSpec.configure do |config|
24
+ config.run_all_when_everything_filtered = true
25
+ config.filter_run :focus
26
+ config.order = 'random'
27
+ config.expect_with( :rspec )
28
+ config.mock_with( :rspec ) do |mock|
29
+ mock.syntax = :expect
30
+ end
31
+
32
+ config.include( Loggability::SpecHelpers )
33
+ end
34
+
35
+ # vim: set nosta noet ts=4 sw=4:
36
+
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env rspec -wfd
2
+
3
+ require_relative '../helpers'
4
+
5
+ require 'symphony/mixins'
6
+
7
+
8
+ describe Symphony, 'mixins' do
9
+
10
+ describe Symphony::MethodUtilities, 'used to extend a class' do
11
+
12
+ let!( :extended_class ) do
13
+ klass = Class.new
14
+ klass.extend( Symphony::MethodUtilities )
15
+ klass
16
+ end
17
+
18
+ it "can declare a class-level attribute reader" do
19
+ extended_class.singleton_attr_reader :foo
20
+ expect( extended_class ).to respond_to( :foo )
21
+ expect( extended_class ).to_not respond_to( :foo= )
22
+ expect( extended_class ).to_not respond_to( :foo? )
23
+ end
24
+
25
+ it "can declare a class-level attribute writer" do
26
+ extended_class.singleton_attr_writer :foo
27
+ expect( extended_class ).to_not respond_to( :foo )
28
+ expect( extended_class ).to respond_to( :foo= )
29
+ expect( extended_class ).to_not respond_to( :foo? )
30
+ end
31
+
32
+ it "can declare a class-level attribute reader and writer" do
33
+ extended_class.singleton_attr_accessor :foo
34
+ expect( extended_class ).to respond_to( :foo )
35
+ expect( extended_class ).to respond_to( :foo= )
36
+ expect( extended_class ).to_not respond_to( :foo? )
37
+ end
38
+
39
+ it "can declare a class-level alias" do
40
+ def extended_class.foo
41
+ return "foo"
42
+ end
43
+ extended_class.singleton_method_alias( :bar, :foo )
44
+
45
+ expect( extended_class.bar ).to eq( 'foo' )
46
+ end
47
+
48
+ it "can declare an instance attribute predicate method" do
49
+ extended_class.attr_predicate :foo
50
+ instance = extended_class.new
51
+
52
+ expect( instance ).to_not respond_to( :foo )
53
+ expect( instance ).to_not respond_to( :foo= )
54
+ expect( instance ).to respond_to( :foo? )
55
+
56
+ expect( instance.foo? ).to eq( false )
57
+
58
+ instance.instance_variable_set( :@foo, 1 )
59
+ expect( instance.foo? ).to eq( true )
60
+ end
61
+
62
+ it "can declare an instance attribute predicate and writer" do
63
+ extended_class.attr_predicate_accessor :foo
64
+ instance = extended_class.new
65
+
66
+ expect( instance ).to_not respond_to( :foo )
67
+ expect( instance ).to respond_to( :foo= )
68
+ expect( instance ).to respond_to( :foo? )
69
+
70
+ expect( instance.foo? ).to eq( false )
71
+
72
+ instance.foo = 1
73
+ expect( instance.foo? ).to eq( true )
74
+ end
75
+
76
+ end
77
+
78
+ end