Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

First public release

  • Loading branch information...
commit 53a9b138028efab5b4255175316feeec1004b5e4 0 parents
@delano authored
7 CHANGES.txt
@@ -0,0 +1,7 @@
+RYE, CHANGES
+
+
+#### 0.3 (2009-04-??) ###############################
+
+Initial public release
+
19 LICENSE.txt
@@ -0,0 +1,19 @@
+Copyright (c) 2009 Delano Mandelbaum, Solutious Inc
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
35 README.rdoc
@@ -0,0 +1,35 @@
+= Rye - v0.1
+
+Run system commands via SSH locally and remotely in a Ruby way.
+
+Rye is similar to Rush[http://rush.heroku.com] but it's all Ruby (no HTTP daemon) and it's less powerful by design.
+
+
+== Installation
+
+One of:
+
+* sudo gem install rye
+* sudo gem install delano-rye --source http://gems.github.com/
+* $ git clone git://github.com/delano/rye.git
+
+== Example
+
+ rbox = Rye::Box.new('filibuster')
+ rbox.hostname # => filibuster
+
+ rbox['/etc/apache'].ls # => ['httpd.conf','mime.types', ...]
+
+
+== Credits
+
+* Delano Mandelbaum (delano@solutious.com)
+
+== Thanks
+
+* Solutious Incorporated (http://solutious.com) for all the orange juice.
+* The country of Canada for making Rye Whiskey.
+
+== License
+
+See: LICENSE.txt
83 Rakefile
@@ -0,0 +1,83 @@
+require 'rubygems'
+require 'rake/clean'
+require 'rake/gempackagetask'
+require 'hanna/rdoctask'
+require 'fileutils'
+include FileUtils
+
+task :default => :package
+
+# CONFIG =============================================================
+
+# Change the following according to your needs
+README = "README.rdoc"
+CHANGES = "CHANGES.txt"
+LICENSE = "LICENSE.txt"
+
+# Files and directories to be deleted when you run "rake clean"
+CLEAN.include [ 'pkg', '*.gem', '.config']
+
+# Virginia assumes your project and gemspec have the same name
+name = (Dir.glob('*.gemspec') || ['rye']).first.split('.').first
+load "#{name}.gemspec"
+version = @spec.version
+
+# That's it! The following defaults should allow you to get started
+# on other things.
+
+
+# TESTS/SPECS =========================================================
+
+
+
+# INSTALL =============================================================
+
+Rake::GemPackageTask.new(@spec) do |p|
+ p.need_tar = true if RUBY_PLATFORM !~ /mswin/
+end
+
+task :release => [ :rdoc, :package ]
+task :install => [ :rdoc, :package ] do
+ sh %{sudo gem install pkg/#{name}-#{version}.gem}
+end
+task :uninstall => [ :clean ] do
+ sh %{sudo gem uninstall #{name}}
+end
+
+
+# RUBYFORGE RELEASE / PUBLISH TASKS ==================================
+
+if @spec.rubyforge_project
+ desc 'Publish website to rubyforge'
+ task 'publish:rdoc' => 'doc/index.html' do
+ sh "scp -rp doc/* rubyforge.org:/var/www/gforge-projects/#{name}/"
+ end
+
+ desc 'Public release to rubyforge'
+ task 'publish:gem' => [:package] do |t|
+ sh <<-end
+ rubyforge add_release -o Any -a #{CHANGES} -f -n #{README} #{name} #{name} #{@spec.version} pkg/#{name}-#{@spec.version}.gem &&
+ rubyforge add_file -o Any -a #{CHANGES} -f -n #{README} #{name} #{name} #{@spec.version} pkg/#{name}-#{@spec.version}.tgz
+ end
+ end
+end
+
+
+
+# RUBY DOCS TASK ==================================
+
+Rake::RDocTask.new do |t|
+ t.rdoc_dir = 'doc'
+ t.title = @spec.summary
+ t.options << '--line-numbers' << '-A cattr_accessor=object'
+ t.options << '--charset' << 'utf-8'
+ t.rdoc_files.include(LICENSE)
+ t.rdoc_files.include(README)
+ t.rdoc_files.include(CHANGES)
+ #t.rdoc_files.include('bin/*')
+ t.rdoc_files.include('lib/**/*.rb')
+end
+
+
+
+
51 lib/rye.rb
@@ -0,0 +1,51 @@
+
+require 'rubygems' unless defined? Gem
+require 'sysinfo'
+require 'escape'
+require 'thread'
+require 'highline'
+require 'rye'
+
+# = Rye
+#
+# Rye is an library for running commands locally (via shell or SSH)
+# and remotely (via SSH).
+#
+# Rye is inspired by the following:
+#
+# * http://github.com/adamwiggins/rush
+# * http://github.com/jamis/capistrano/blob/master/lib/capistrano/shell.rb
+# * http://www.nofluffjuststuff.com/blog/david_bock/2008/10/ruby_s_closure_cleanup_idiom_and_net_ssh.html
+# * http://groups.google.com/group/ruby-talk-google/browse_thread/thread/674a6f6de15ceb49?pli=1
+# * http://paste.lisp.org/display/6912
+#
+module Rye
+ extend self
+ unless defined?(SYSINFO)
+ VERSION = 0.1.freeze
+ SYSINFO = SystemInfo.new.freeze
+ end
+
+ def Rye.sysinfo; SYSINFO; end
+ def sysinfo; SYSINFO; end
+
+ class CommandNotFound < RuntimeError; end
+ class NoHost < RuntimeError; end
+ class NotConnected < RuntimeError; end
+
+ # Reload Rye dynamically. Useful with irb.
+ def reload
+ pat = File.join(File.dirname(__FILE__), 'rye', '**', '*.rb')
+ Dir.glob(pat).collect { |file| load file; file; }
+ end
+
+ #def run
+ #@bgthread = Thread.new do
+ # loop { @mutex.synchronize { approach } }
+ #end
+ #@bgthread.join
+ #end
+end
+
+
+Rye.reload
283 lib/rye/box.rb
@@ -0,0 +1,283 @@
+
+
+module Rye
+
+
+ # = Rye::Box
+ #
+ # The Rye::Box class represents a machine. All system
+ # commands are made through this class.
+ #
+ # rbox = Rye::Box.new('filibuster')
+ # rbox.hostname # => filibuster
+ #
+ # You can also run local commands through SSH
+ #
+ # rbox = Rye::Box.new('localhost)
+ # rbox.hostname # => localhost
+ #
+ # rbox = Rye::Box.new
+ # rbox.hostname # => localhost
+ #
+ class Box
+ include Rye::Box::Commands
+
+ @@agent_env ||= Hash.new # holds ssh-agent env vars
+
+ # An instance of Net::SSH::Connection::Session
+ attr_reader :ssh
+ attr_reader :stdout
+ attr_reader :stderr
+
+ attr_accessor :host
+ attr_accessor :user
+
+ def initialize(host='localhost', user=nil, opts={})
+ user ||= Rye.sysinfo.user
+
+ opts = {
+ :keypairs => [],
+ :stdout => STDOUT,
+ :stderr => STDERR,
+ }.merge(opts)
+
+ @mutex = Mutex.new
+ @mutex.synchronize { Box.start_sshagent_environment } # One thread only
+
+ @host = host
+ @user = user
+ @keypaths = add_keys(opts[:keypaths])
+ @stdout = opts[:stdout]
+ @stderr = opts[:stderr]
+ end
+
+
+ at_exit {
+ Box.end_sshagent_environment
+ }
+
+ # Returns an Array of system commands available over SSH
+ def can
+ Rye::Box::Commands.instance_methods
+ end
+ alias :commands :can
+
+ # Change the current working directory (sort of).
+ #
+ # I haven't been able to wrangle Net::SSH to do my bidding.
+ # "My bidding" in this case, is maintaining an open channel between commands.
+ # I'm using Net::SSH::Connection::Session#exec! for all commands
+ # which is like a funky helper method that opens a new channel
+ # each time it's called. This seems to be okay for one-off
+ # commands but changing the directory only works for the channel
+ # it's executed in. The next time exec! is called, there's a
+ # new channel which is back in the default (home) directory.
+ #
+ # Long story short, the work around is to maintain the current
+ # directory locally and send it with each command.
+ #
+ # rbox.pwd # => /home/rye ($ pwd )
+ # rbox['/usr/bin'].pwd # => /usr/bin ($ cd /usr/bin && pwd)
+ # rbox.pwd # => /usr/bin ($ cd /usr/bin && pwd)
+ #
+ def [](key=nil)
+ @current_working_directory = key
+ self
+ end
+ alias :cd :'[]'
+
+
+ # Add an environment variable to the command
+ def add_env(n, v)
+
+ end
+
+ # Open an SSH session with +@host+.
+ # Raises a Rye::NoHost exception if +@host+ is not specified.
+ def connect
+ raise Rye::NoHost unless @host
+ disconnect if @ssh
+ @stderr.puts "Opening connection to #{@host}"
+ @ssh = Net::SSH.start(@host, @user)
+ @ssh.is_a?(Net::SSH::Connection::Session) && !@ssh.closed?
+ self
+ end
+
+ # Close the SSH session with +@host+
+ def disconnect
+ return unless @ssh && !@ssh.closed?
+ @ssh.loop(0.1) { @ssh.busy? }
+ @stderr.puts "Closing connection to #{@ssh.host}"
+ @ssh.close
+ end
+
+ # Add one or more private keys to the SSH Agent.
+ # * +additional_keys+ is a list of file paths to private keys
+ # Returns the instance of Box
+ def add_keys(*additional_keys)
+ additional_keys = [additional_keys].flatten.compact || []
+ Rye::Box.shell("ssh-add", additional_keys) if additional_keys
+ Rye::Box.shell("ssh-add") # Add the user's default keys
+ self
+ end
+
+ # Returns an Array of info about the currently available
+ # SSH keys, as provided by the SSH Agent. See
+ # Box.start_sshagent_environment
+ #
+ # Returns: [[bits, finger-print, file-path], ...]
+ def keys
+ # 2048 76:cb:d7:82:90:92:ad:75:3d:68:6c:a9:21:ca:7b:7f /Users/rye/.ssh/id_rsa (RSA)
+ # 2048 7b:a6:ba:55:b1:10:1d:91:9f:73:3a:aa:0c:d4:88:0e /Users/rye/.ssh/id_dsa (DSA)
+ keystr = Rye::Box.shell("ssh-add", '-l')
+ return nil unless keystr
+ keystr.split($/).collect do |key|
+ key.split(/\s+/)
+ end
+ end
+
+ # Takes a command with arguments and returns it in a
+ # single String with escaped args and some other stuff.
+ #
+ # * +args+ An Array. The first element must be the
+ # command name, the rest are its aruments.
+ #
+ # The command is searched for in the local PATH (where
+ # Rye is running). An exception is raised if it's not
+ # found. NOTE: Because this happens locally, you won't
+ # want to use this method if the environment is quite
+ # different from the remote machine it will be executed
+ # on.
+ #
+ # The command arguments are passed through Escape.shell_command
+ # (that means you can't use environment variables or asterisks).
+ #
+ def Box.prepare_command(*args)
+ args &&= [args].flatten.compact
+ cmd = args.shift
+ cmd = Rye::Box.which(cmd)
+ raise CommandNotFound.new(cmd || 'nil') unless cmd
+ Escape.shell_command([cmd, *args]).to_s
+ end
+
+ # An all ruby implementation of unix "which" command.
+ #
+ # * +executable+ the name of the executable
+ #
+ # Returns the absolute path if found in PATH otherwise nil.
+ def Box.which(executable)
+ return unless executable.is_a?(String)
+ #return executable if File.exists?(executable) # SHOULD WORK, MUST TEST
+ shortname = File.basename(executable)
+ dir = Rye.sysinfo.paths.select do |path| # dir contains all of the
+ next unless File.exists? path # occurrences of shortname
+ Dir.new(path).entries.member?(shortname) # found in the paths.
+ end
+ File.join(dir.first, shortname) unless dir.empty? # Return just the first
+ end
+
+ # Execute a local system command (via the shell, not SSH)
+ #
+ # * +cmd+ the executable path (relative or absolute)
+ # * +args+ Array of arguments to be sent to the command. Each element
+ # is one argument:. i.e. <tt>['-l', 'some/path']</tt>
+ #
+ # NOTE: shell is a bit paranoid so it escapes every argument. This means
+ # you can only use literal values. That means no asterisks too.
+ #
+ def Box.shell(cmd, args=[])
+ # TODO: allow stdin to be send to cmd
+ cmd = Box.prepare_command(cmd, args)
+ cmd << " 2>&1" # Redirect STDERR to STDOUT. Works in DOS also.
+ handle = IO.popen(cmd, "r")
+ output = handle.read.chomp
+ handle.close
+ output
+ end
+
+ private
+
+ # Start the SSH Agent locally. This is important
+ # primarily because Rye relies on it for SSH key
+ # management. If the agent doesn't start then
+ # passwordless logins won't work.
+ #
+ # This method starts an instances of ssh-agent
+ # and sets the appropriate environment so all
+ # local commands run by Rye will have access be aware
+ # of this instance of the agent too.
+ #
+ # The equivalent commands on the shell are:
+ #
+ # $ ssh-agent -s
+ # SSH_AUTH_SOCK=/tmp/ssh-tGvaOXIXSr/agent.12951; export SSH_AUTH_SOCK;
+ # SSH_AGENT_PID=12952; export SSH_AGENT_PID;
+ # $ SSH_AUTH_SOCK=/tmp/ssh-tGvaOXIXSr/agent.12951; export SSH_AUTH_SOCK;
+ # $ SSH_AGENT_PID=12952; export SSH_AGENT_PID;
+ #
+ # NOTE: The OpenSSL library (The C one, not the Ruby one)
+ # must be installed for this to work.
+ #
+ def Box.start_sshagent_environment
+ return if @@agent_env["SSH_AGENT_PID"]
+
+ lines = Rye::Box.shell("ssh-agent", '-s') || ''
+ lines.split($/).each do |line|
+ next unless line.index("echo").nil?
+ line = line.slice(0..(line.index(';')-1))
+ key, value = line.chomp.split( /=/ )
+ @@agent_env[key] = value
+ end
+ ENV["SSH_AUTH_SOCK"] = @@agent_env["SSH_AUTH_SOCK"]
+ ENV["SSH_AGENT_PID"] = @@agent_env["SSH_AGENT_PID"]
+ nil
+ end
+
+ # Kill the local instance of the SSH Agent we started.
+ #
+ # $ echo $SSH_AGENT_PID
+ # 99416
+ # $ kill -9 99416
+ #
+ def Box.end_sshagent_environment
+ pid = @@agent_env["SSH_AGENT_PID"]
+ Rye::Box.shell("kill", ['-9', pid]) if pid
+ nil
+ end
+
+ # Execute a command over SSH
+ #
+ # * +args+ is a command name and list of arguments.
+ # The command name is the literal name of the command
+ # that will be executed in the remote shell. The arguments
+ # will be thoroughly escaped and passed to the command.
+ #
+ # rbox = Rye::Box.new
+ # rbox.ls '-l', 'arg1', 'arg2'
+ #
+ # is equivalent to
+ #
+ # $ ls -l 'arg1' 'arg2'
+ #
+ def command(*args)
+ connect if !@ssh || @ssh.closed?
+ raise Rye::NotConnected, @host unless @ssh && !@ssh.closed?
+ args = args.first.split(/\s+/) if args.size == 1
+ cmd, args = args.flatten.compact
+ cmd_clean = Escape.shell_command(cmd, *args).to_s
+ cmd_clean << " 2>&1" # STDERR into STDOUT. Works in DOS also.
+ if @current_working_directory
+ cwd = Escape.shell_command('cd', @current_working_directory)
+ cmd_clean = "%s && %s" % [cwd, cmd_clean]
+ end
+ @stderr.puts "Executing: %s" % cmd_clean
+ output = @ssh.exec! cmd_clean
+ Rye::Box::Response.new(self, (output || '').split($/))
+ end
+
+
+
+ end
+end
+
+
36 lib/rye/box/commands.rb
@@ -0,0 +1,36 @@
+
+module Rye; class Box;
+
+ # = Rye::Box::Commands
+ #
+ # This class contains all of the shell command methods
+ # available to an instance of Rye::Box. For security and
+ # general safety, Rye only permits this whitelist of
+ # commands by default. However, you're free to add methods
+ # with mixins.
+ #
+ # require 'rye'
+ # module Rye::Box::Commands
+ # def uptime; command("uptime"); end
+ # def sleep(seconds=1); command("sleep", seconds); end
+ # def special(*args); command("/your/special/command", args); end
+ # end
+ #
+ # rbox = Rye::Box.new
+ # rbox.uptime # => 11:02 up 8 days, 17:17, 2 users
+ #
+ module Commands
+ def wc(*args); command('wc', args); end
+ def cp(*args); command("cp", args); end
+ def mv(*args); command("mv", args); end
+ def ls(*args); command('ls', args); end
+ def env; command "env"; end
+ def pwd(key=nil); command "pwd"; end
+ def date(*args); command('date', args); end
+ def echo(*args); command('echo', args); end
+ def sleep(seconds=1); command("sleep", seconds); end
+ def mount; command("mount"); end
+ def uptime; command("uptime"); end
+ end
+
+end; end
46 lib/rye/box/response.rb
@@ -0,0 +1,46 @@
+
+
+module Rye; class Box;
+
+ # Rye::Box::Response
+ #
+ # This class is a modified Array which is returned by
+ # all command methods. The commands output is split
+ # by line into an instance of this class. If there is
+ # only a single element it will act like a String.
+ #
+ # This class also contains a reference to the instance
+ # of Rye::Box that the command was executed on.
+ #
+ class Response < Array
+ # A reference to the Rye::Bos instance the command
+ # was executed on.
+ attr_reader :box
+
+ # * +b+ an instance of Rye::Box
+ # * +args+ anything that can sent to Array#new
+ def initialize(b, *args)
+ @box = b
+ super *args
+ end
+
+ # Returns the first element if there it's the only
+ # one, otherwise the value of Array#to_s
+ def to_s
+ return self.first if self.size == 1
+ super
+ end
+
+ #---
+ # If Box's shell methods return Response objects, then
+ # we can do stuff like this
+ # rbox.cp '/etc' | rbox2['/tmp']
+ #def |(other)
+ # puts "BOX1", self.join($/)
+ # puts "BOX2", other.join($/)
+ #end
+ #+++
+
+ end
+
+end; end
59 rye.gemspec
@@ -0,0 +1,59 @@
+@spec = Gem::Specification.new do |s|
+ s.name = "rye"
+ s.rubyforge_project = "rye"
+ s.version = "0.1"
+ s.summary = "Run system commands via SSH locally and remotely in a Ruby way."
+ s.description = s.summary
+ s.author = "Delano Mandelbaum"
+ s.email = "delano@solutious.com"
+ s.homepage = "http://solutious.com/"
+
+ # = DEPENDENCIES =
+ # Add all gem dependencies
+ s.add_dependency 'net-ssh'
+ s.add_dependency 'highline'
+
+ # = MANIFEST =
+ # The complete list of files to be included in the release. When GitHub packages your gem,
+ # it doesn't allow you to run any command that accesses the filesystem. You will get an
+ # error. You can ask your VCS for the list of versioned files:
+ # git ls-files
+ # svn list -R
+ s.files = %w(
+ CHANGES.txt
+ LICENSE.txt
+ README.rdoc
+ Rakefile
+ lib/rye.rb
+ lib/rye/box.rb
+ lib/rye/box/commands.rb
+ lib/rye/box/response.rb
+ rye.gemspec
+ )
+
+ # = EXECUTABLES =
+ # The list of executables in your project (if any). Don't include the path,
+ # just the base filename.
+ s.executables = %w[]
+
+
+ s.extra_rdoc_files = %w[README.rdoc LICENSE.txt]
+ s.has_rdoc = true
+ s.rdoc_options = ["--line-numbers", "--title", s.summary, "--main", "README.rdoc"]
+ s.require_paths = %w[lib]
+ s.rubygems_version = '1.3.0'
+
+ if s.respond_to? :specification_version then
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
+ s.specification_version = 2
+
+ if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
+ s.add_runtime_dependency(%q<RedCloth>, [">= 4.0.4"])
+ else
+ s.add_dependency(%q<RedCloth>, [">= 4.0.4"])
+ end
+ else
+ s.add_dependency(%q<RedCloth>, [">= 4.0.4"])
+ end
+
+end
Please sign in to comment.
Something went wrong with that request. Please try again.