symphony 0.3.0.pre20140327204419

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.
@@ -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