hansolo 0.0.1.alpha.3 → 0.1.0

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.
data/.yardopts ADDED
@@ -0,0 +1 @@
1
+ --markup markdown --title hansolo --protected -M github-markup -M redcarpet lib/**/*.rb
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  NOTE: This is alpha code.
4
4
 
5
- Cli tool to automate berkshelf and chef-solo deployment
5
+ CLI tool to automate berkshelf and chef-solo deployment
6
6
 
7
7
  ## Installation
8
8
 
@@ -18,25 +18,89 @@ Or install it yourself as:
18
18
 
19
19
  $ gem install hansolo
20
20
 
21
- ## Example configuration file
21
+ ## Usage
22
22
 
23
- {
24
- "urls": [ "vagrant@localhost:2222" ],
25
- "runlist": [ "my_app::deploy" ],
26
- "app":"my_app",
27
- "keydir":"/Applications/Vagrant/embedded/gems/gems/vagrant-1.1.4/keys/vagrant",
28
- "aws_access_key_id":"AAAAAAAAAAAAAAAAAAAA",
29
- "aws_secret_access_key":"1111111111111111111111111111111111111111",
30
- "aws_bucket_name":"acme-data_bags",
31
- "aws_data_bag_keys":["my_app/stage/environment.json"]
32
- }
23
+ `hansolo` provides three command line utilities for managing nodes with `chef-solo`.
33
24
 
34
- ## Usage
25
+ * `hansolo`: runs `rsync` to copy cookbooks and data bags to the target nodes, generates a manifest and executes `chef-solo` against the generated manifest.
26
+ * `hansolo-databag`: Manages data bags.
27
+ * `hansolo-ssh`: SSHs into one of the target nodes.
28
+
29
+ To see what options can be supplied, run the command with `-h` or `--help`.
30
+
31
+
32
+ ## `Hanfile` options
33
+
34
+ ```ruby
35
+ Hansolo.configure do |config|
36
+ # Path to SSH keys
37
+ config.keydir = '~/.ssh/chef'
38
+
39
+ # Gateway server if nodes are in a private network. Must be a valid ssh URI
40
+ # or URI instance.
41
+ config.gateway = 'ssh://user@gateway.example.com:20202'
42
+
43
+ # Name of the application
44
+ config.app = 'blog'
45
+
46
+ # Nodes to run `chef-solo` on. Can be a single or array of ssh URIs or URI
47
+ # instance.
48
+ config.target = 'ssh://user@blog.example.com'
49
+
50
+ # List of recipes to run.
51
+ config.runlist = ['recipe']
52
+
53
+ # Local path where cookbooks should be installed to using
54
+ # `Hansolo.librarian`. Defaults to `./tmp/data_bags`
55
+ config.cookbooks_path = '/tmp/chef/cookbooks'
56
+
57
+ # Local path where data bags will be written when using `hansolo-databag`.
58
+ # Defaults to `./tmp/cookbooks`
59
+ config.data_bags_path = '/tmp/chef/cookbooks'
60
+
61
+ # Command to run on the node after SSHing.
62
+ config.post_ssh_command = 'export RAILS_ENV=production; cd /srv/blog/current'
63
+
64
+ # Which chef cookbook manager to use. Currently, only `#berkshelf` is
65
+ # supported.
66
+ config.librarian = :berkshelf
67
+
68
+ # SSH options to use when running `rsync` or `hansolo-ssh`.
69
+ # Defaults to `-q -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no`.
70
+ config.ssh_options = '-vvv'
71
+ end
72
+ ```
73
+
74
+ ## Providers
75
+
76
+ `hansolo`'s behavior can be augmented by different providers by requiring them
77
+ in a `Hanfile`. Currently, AWS is the only provider provided.
78
+
79
+ ```ruby
80
+ # Add AWS functionality to the toolset
81
+ require 'hansolo/providers/aws'
82
+
83
+ Hansolo.configure do |config|
84
+ # ...
85
+ end
86
+ ```
87
+
88
+ ### AWS Provider
89
+
90
+ The AWS provider augments `hansolo` to store data\_bags in S3 and adds the
91
+ ability to query EC2 for the IP address of the gateway and/or target nodes.
35
92
 
36
- See the binary:
93
+ Data bags are stored in a bucket with the name `data_bags-:app`. The bucket is
94
+ created if it does not exist.
37
95
 
38
- $ hansolo -h
96
+ To have the IP address of the gateway queried, use the following URI scheme:
97
+ `<tag_name>://user@<value>:port`. The `<tag_name>` is the name of any tag on
98
+ the instance (e.g. `Name://user@bastion`).
39
99
 
100
+ To query instances, set `Hansolo.target` to a hash with the keys `:user`,
101
+ `:host`, and optionally `:port` (if not `22`). The `:host` key should be set to
102
+ the tag to query and the value should be the name of the application (e.g.
103
+ `role://user@api`).
40
104
 
41
105
  ## Contributing
42
106
 
data/bin/hansolo CHANGED
@@ -1,53 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
  $: << File.expand_path(File.join(__FILE__, '..', '..', 'lib'))
3
3
 
4
- require 'hansolo'
5
- require 'optparse'
4
+ require 'hansolo/commands/solo'
6
5
 
7
- conf_options = {}; options = {}
8
-
9
- optparse = OptionParser.new do |opts|
10
- opts.banner = Hansolo::Cli.banner
11
-
12
- opts.on( '-h', '--help', 'Display this screen' ) do
13
- puts opts
14
- puts "\n"
15
- puts Hansolo::Cli.help
16
- exit
17
- end
18
-
19
- opts.on( '-c', '--config file', String, 'Path to config file') do |filename|
20
- conf_options = JSON.parse(File.read(filename)) if filename != '' and File.exists?(filename)
21
- end
22
-
23
- opts.on( '-u', '--urls a,b,c', Array, "Comma-sep list of urls, e.g.: user@host:port/dest/path") do |o|
24
- options[:urls] = o
25
- end
26
-
27
- opts.on( '-k', '--keydir s', String, "Your local ssh key directory") do |o|
28
- options[:keydir] = o
29
- end
30
-
31
- opts.on( '-a', '--app s', String, "The application name") do |o|
32
- options[:app] = o
33
- end
34
-
35
- opts.on( '-s', '--stage s', String, "The stage name") do |o|
36
- options[:stage] = o
37
- end
38
-
39
- opts.on( '-r', '--runlist a,b,c', Array, "The runlist you want to effect on the target(s)") do |o|
40
- options[:runlist] = o
41
- end
42
- end.parse!(ARGV)
43
-
44
- unless conf_options.any?
45
- default_conf_filename = File.expand_path(File.join(".",".hansolo.json"))
46
- conf_options = JSON.parse(File.read(default_conf_filename)) if File.exists?(default_conf_filename)
47
- end
48
-
49
-
50
- opts = conf_options.merge(options).inject({}){|m,(k,v)| m[k.to_sym] = v; m}
51
-
52
- h = Hansolo::Cli.new conf_options.merge(opts)
53
- h.all!
6
+ Hansolo::Commands::Solo.run(ARGV)
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ $: << File.expand_path(File.join(__FILE__, '..', '..', 'lib'))
3
+
4
+ require 'hansolo/commands/data_bag'
5
+
6
+ Hansolo::Commands::DataBag.run(ARGV)
data/bin/hansolo-ssh ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ $: << File.expand_path(File.join(__FILE__, '..', '..', 'lib'))
3
+
4
+ require 'hansolo/commands/ssh'
5
+
6
+ Hansolo::Commands::SSH.run(ARGV)
data/hansolo.gemspec CHANGED
@@ -20,9 +20,14 @@ Gem::Specification.new do |spec|
20
20
 
21
21
  spec.add_dependency "aws-sdk"
22
22
  spec.add_dependency "net-ssh"
23
+ spec.add_dependency "net-ssh-gateway"
23
24
  spec.add_dependency "json"
25
+ spec.add_dependency "terminal-table"
26
+ spec.add_dependency "cocaine"
24
27
 
25
28
  spec.add_development_dependency "bundler", "~> 1.3"
26
29
  spec.add_development_dependency "rake"
27
30
  spec.add_development_dependency "mocha"
31
+ spec.add_development_dependency "yard"
32
+ spec.add_development_dependency "redcarpet"
28
33
  end
@@ -0,0 +1,85 @@
1
+ require 'optparse'
2
+ require 'cocaine'
3
+ require 'net/ssh'
4
+ require 'net/ssh/gateway'
5
+ require 'hansolo'
6
+ require 'hansolo/providers/default'
7
+
8
+ module Hansolo
9
+ module Commands
10
+ # Responsible for taking in command line options and reading in `Hanfile`.
11
+ # Any unique command line options should be added in a subclass. Provides
12
+ # minimal helpers for executing commands.
13
+ class Base
14
+ include Providers::DefaultBehavior
15
+
16
+ # @!attribute [r] bastion
17
+ # @return [URI] attributes of the bastion server
18
+ attr_reader :bastion
19
+
20
+ # Run the command
21
+ # @see {#run}
22
+ def self.run(arguments)
23
+ new(arguments).run
24
+ end
25
+
26
+ # Sets up command
27
+ #
28
+ # * Loads the `Hanfile`
29
+ # * Parses command line arguments
30
+ # * Determines the {#bastion} if {Hansolo.gateway} is specified
31
+ def initialize(arguments)
32
+ load_hanfile!
33
+
34
+ setup_parser
35
+ parser.parse!(arguments)
36
+
37
+ determine_bastion if Hansolo.gateway
38
+ end
39
+
40
+ # Public interface to the command to be implemented in a subclass.
41
+ def run
42
+ raise NotImplementedError
43
+ end
44
+
45
+ private
46
+
47
+ def exec(command)
48
+ Hansolo.logger.debug(command)
49
+ Kernel.exec(command)
50
+ end
51
+
52
+ def call(command)
53
+ Hansolo.logger.debug(command)
54
+ %x{#{command}}
55
+ end
56
+
57
+ def parser
58
+ @parser ||= OptionParser.new
59
+ end
60
+
61
+ def load_hanfile!
62
+ load hanfile_path if File.exists?(hanfile_path)
63
+ end
64
+
65
+ def hanfile_path
66
+ @hanfile_path ||= File.expand_path('Hanfile')
67
+ end
68
+
69
+ def setup_parser
70
+ parser.on( '-h', '--help', 'display this screen' ) do
71
+ puts parser
72
+ exit
73
+ end
74
+
75
+ parser.on( '-t', '--target a,b,c', Array, "comma-sep list of urls, e.g.: user@host:port/dest/path") do |option|
76
+ Hansolo.target = option
77
+ end
78
+
79
+ parser.on( '-a', '--app s', String, "the application name") do |option|
80
+ Hansolo.app = option
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,82 @@
1
+ require 'terminal-table'
2
+ require 'hansolo/commands/base'
3
+ require 'hansolo/providers/default/data_bags'
4
+
5
+ module Hansolo
6
+ module Commands
7
+ class DataBag < Base
8
+ include Providers::DefaultBehavior::DataBags
9
+
10
+ attr_accessor :bag, :item, :changes
11
+
12
+ def run
13
+ changes.nil? ? print : write and print
14
+ end
15
+
16
+ def changes=(key_value_pairs)
17
+ @changes = key_value_pairs.inject({}) do |hash, pair|
18
+ key, value = pair.split('=', 2)
19
+ hash[key] = value
20
+ hash
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def read(content = item_content)
27
+ JSON.parse(content)
28
+ end
29
+
30
+ def all
31
+ data_bags.map { |key, content| [key, read(content)] }
32
+ end
33
+
34
+ def write
35
+ content = read.merge(changes).delete_if { |k, v| v.nil? || v.strip.empty? }
36
+ content['id'] ||= item
37
+
38
+ write_to_storage(content.to_json)
39
+ end
40
+
41
+ def print
42
+ if !bag.nil? && !item.nil?
43
+ rows = read
44
+ rows.delete('id')
45
+
46
+ terminal_table = Terminal::Table.new(rows: rows, headings: ['key', 'value'])
47
+ else
48
+ terminal_table = Terminal::Table.new do |table|
49
+ table.headings = ['key', 'value']
50
+ all.each_with_index do |(bag_and_item, content), i|
51
+ table.add_separator if i != 0
52
+
53
+ table.add_row [{ value: ' ', colspan: 2, alignment: :center, border_y: ' ' }]
54
+ table.add_row [{ value: "BAG/ITEM: #{bag_and_item}", colspan: 2, alignment: :center }]
55
+
56
+ table.add_separator
57
+
58
+ content.delete('id')
59
+ content.each do |k, v|
60
+ table.add_row [k, v]
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ STDOUT.puts terminal_table
67
+ end
68
+
69
+ def setup_parser
70
+ super
71
+
72
+ parser.on('-b', '--data-bag-and-item BAG/ITEM', String, 'The data-bag and data-item, e.g. config/environment') do |option|
73
+ self.bag, self.item = option.split('/')
74
+ end
75
+
76
+ parser.on('--set CONFIG', Array, 'Set or unset (with an empty value) key-value pairs, e.g. foo=bar,key=value') do |option|
77
+ self.changes = option
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,79 @@
1
+ require 'hansolo/commands/base'
2
+ require 'hansolo/providers/default/solo'
3
+
4
+ module Hansolo
5
+ module Commands
6
+ class Solo < Base
7
+ include Providers::DefaultBehavior::Solo
8
+
9
+ # Puts cookbooks and data bags on the target nodes and runs `chef-solo`.
10
+ # Providers should implement the {#sync_data_bags} and {#sync_cookbooks}.
11
+ def run
12
+ sync_data_bags
13
+
14
+ Hansolo.librarian.install!
15
+ sync_cookbooks
16
+
17
+ execute_chef_solo
18
+ end
19
+
20
+ # SSH into each node to prepare and execute a `chef-solo` run.
21
+ def execute_chef_solo
22
+ threads = hosts.map do |host|
23
+ ssh = connect(host)
24
+
25
+ Thread.new do
26
+ ssh.exec! generate_manifest.command(manifest: manifest)
27
+ ssh.exec! generate_json.command(json: json)
28
+
29
+ ssh.exec! chef_solo do |channel, stream, line|
30
+ puts line
31
+ end
32
+
33
+ ssh.close
34
+ end
35
+ end
36
+
37
+ threads.map(&:join)
38
+ end
39
+
40
+ private
41
+
42
+ def chef_solo
43
+ 'sudo chef-solo -c /tmp/solo.rb -j /tmp/deploy.json'
44
+ end
45
+
46
+ def connect(host)
47
+ if bastion.nil?
48
+ Net::SSH.new(host.host, host.user, port: host.port)
49
+ else
50
+ gateway.ssh(host.host, host.user, port: host.port)
51
+ end
52
+ end
53
+
54
+ def generate_manifest
55
+ Cocaine::CommandLine.new('echo', ':manifest > /tmp/solo.rb')
56
+ end
57
+
58
+ def generate_json
59
+ Cocaine::CommandLine.new('echo', ':json > /tmp/deploy.json')
60
+ end
61
+
62
+ def manifest
63
+ <<-MANIFEST
64
+ file_cache_path '/tmp'
65
+ cookbook_path '/tmp/cookbooks'
66
+ data_bag_path '/tmp/data_bags'
67
+ MANIFEST
68
+ end
69
+
70
+ def json
71
+ { :run_list => Hansolo.runlist }.to_json
72
+ end
73
+
74
+ def gateway
75
+ @gateway ||= Net::SSH::Gateway.new(bastion.host, bastion.user, port: bastion.port)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,58 @@
1
+ require 'hansolo/commands/base'
2
+
3
+ module Hansolo::Commands
4
+ class SSH < Base
5
+ def run
6
+ if bastion.nil?
7
+ exec(ssh.command(ssh_params))
8
+ else
9
+ exec(bastion_ssh.command(bastion_params))
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def post_ssh_command
16
+ "#{Hansolo.post_ssh_command}; bash -i"
17
+ end
18
+
19
+ def ssh
20
+ Cocaine::CommandLine.new('ssh', ssh_params)
21
+ end
22
+
23
+ def ssh_options
24
+ options = ":user@:host #{Hansolo.ssh_options} -p :port"
25
+ options << ' -t :command' if Hansolo.post_ssh_command
26
+ options
27
+ end
28
+
29
+ def ssh_params
30
+ @ssh_params ||= begin
31
+ uri = hosts.sample
32
+
33
+ {
34
+ user: uri.user,
35
+ host: uri.host,
36
+ port: uri.port.to_s,
37
+ command: post_ssh_command
38
+ }
39
+ end
40
+ end
41
+
42
+ def bastion_ssh
43
+ Cocaine::CommandLine.new('ssh', bastion_ssh_options)
44
+ end
45
+
46
+ def bastion_ssh_options
47
+ "-A -l :bastion_user #{Hansolo.ssh_options} -p :bastion_port :bastion_host -t \"ssh #{ssh_options}\""
48
+ end
49
+
50
+ def bastion_params
51
+ @bastion_params ||= {
52
+ bastion_user: bastion.user,
53
+ bastion_port: bastion.port.to_s,
54
+ bastion_host: bastion.host
55
+ }.merge(ssh_params)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,15 @@
1
+ module Hansolo::Librarians
2
+ module Berkshelf
3
+ module_function
4
+
5
+ def install!
6
+ directory = Pathname.new("tmp/cookbooks/#{Hansolo.app}")
7
+ FileUtils.mkdir_p(directory)
8
+
9
+ files = Dir[directory.join('*')]
10
+ FileUtils.rm_rf(files)
11
+
12
+ system("berks install --path #{directory}")
13
+ end
14
+ end
15
+ end
@@ -0,0 +1 @@
1
+ require 'hansolo/librarians/berkshelf'
@@ -0,0 +1,25 @@
1
+ module Hansolo::Providers::AWS
2
+ module DataBags
3
+ def data_bags
4
+ objects = bucket.objects.with_prefix(Hansolo.app).to_a
5
+ objects.map do |o|
6
+ key = o.key.chomp('.json').sub("#{Hansolo.app}/", '')
7
+ [key, o.read]
8
+ end
9
+ end
10
+
11
+ def item_key
12
+ @item_key ||= "#{Hansolo.app}/#{bag}/#{item}.json"
13
+ end
14
+
15
+ def item_content
16
+ bucket.objects[item_key].read
17
+ rescue AWS::S3::Errors::NoSuchKey
18
+ "{}"
19
+ end
20
+
21
+ def write_to_storage(content)
22
+ bucket.objects[item_key].write(content)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,58 @@
1
+ module Hansolo
2
+ module Providers
3
+ module AWS
4
+ module Discovery
5
+ def ec2
6
+ @ec2 ||= ::AWS::EC2.new(Hansolo.aws_credentials)
7
+ end
8
+
9
+ def s3
10
+ @s3 ||= ::AWS::S3.new(Hansolo.aws_credentials)
11
+ end
12
+
13
+ def determine_bastion
14
+ @bastion = begin
15
+ uri = super
16
+
17
+ return uri if uri.scheme == 'ssh'
18
+
19
+ instance = instances_by_tag(uri.scheme.to_s, uri.host).first
20
+ raise ArgumentError, "no gateway with #{uri.scheme} #{uri.host} found" if instance.nil?
21
+
22
+ URI.parse("ssh://#{uri.user}@#{instance.public_ip_address}:#{uri.port || 22}")
23
+ end
24
+ end
25
+
26
+ def hosts
27
+ @hosts ||= begin
28
+ target = Hansolo.target
29
+ return super unless target.is_a?(Hash)
30
+
31
+ target_instances = instances_by_tag(target[:host].to_s, Hansolo.app)
32
+
33
+ target_instances.map do |instance|
34
+ ip_address = instance.ip_address || instance.private_ip_address
35
+ URI.parse("ssh://#{target[:user]}@#{ip_address}:#{target[:port] || 22}")
36
+ end
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def bucket
43
+ @bucket ||= begin
44
+ name = Hansolo.bucket_name
45
+
46
+ bucket = s3.buckets[name]
47
+ bucket = s3.buckets.create(name) unless bucket.exists?
48
+ bucket
49
+ end
50
+ end
51
+
52
+ def instances_by_tag(tag, value)
53
+ ec2.instances.tagged(tag).tagged_values(value)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,27 @@
1
+ module Hansolo::Providers::AWS
2
+ module Solo
3
+ def sync_data_bags
4
+ threads = hosts.map do |host|
5
+ Thread.new do
6
+ ssh = connect(host)
7
+
8
+ command = data_bag_items.inject([]) do |cmd, object|
9
+ path = Pathname.new('/tmp/data_bags').join(object.key)
10
+
11
+ cmd << "mkdir -p #{path.dirname}"
12
+ cmd << "echo '#{object.read}' > #{path}"
13
+ end
14
+
15
+ ssh.exec! command.join('; ')
16
+ ssh.close
17
+ end
18
+ end
19
+
20
+ threads.map(&:join)
21
+ end
22
+
23
+ def data_bag_items
24
+ bucket.objects.select { |o| o.key =~ /\.json$/ }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,30 @@
1
+ require 'aws-sdk'
2
+ require 'hansolo'
3
+ require 'hansolo/providers/aws/data_bags'
4
+ require 'hansolo/providers/aws/discovery'
5
+ require 'hansolo/providers/aws/solo'
6
+
7
+ module Hansolo
8
+ class << self
9
+ attr_accessor :aws_access_key_id, :aws_secret_access_key, :bucket_name
10
+ end
11
+
12
+ def self.aws_credentials
13
+ @aws_credentials ||= {
14
+ access_key_id: aws_access_key_id,
15
+ secret_access_key: aws_secret_access_key
16
+ }
17
+ end
18
+
19
+ class Commands::Base
20
+ include Providers::AWS::Discovery
21
+ end
22
+
23
+ class Commands::DataBag
24
+ include Providers::AWS::DataBags
25
+ end
26
+
27
+ class Commands::Solo
28
+ include Providers::AWS::Solo
29
+ end
30
+ end
@@ -0,0 +1,29 @@
1
+ module Hansolo::Providers::DefaultBehavior
2
+ module DataBags
3
+ # Key-value pairs of the name of the data bag item to the item's content.
4
+ # @return [Hash]
5
+ def data_bags
6
+ @data_bags ||= Dir[Hansolo.data_bags_path.join('*', '**')].map { |path| [path.chomp('.json'), load_content(path)] }
7
+ end
8
+
9
+ # Path to the
10
+ def item_path
11
+ Hansolo.data_bags_path.join(bag, "#{item}.json")
12
+ end
13
+
14
+ def load_content(path)
15
+ File.read(path)
16
+ end
17
+
18
+ def item_content
19
+ load_content(item_path)
20
+ rescue
21
+ '{}'
22
+ end
23
+
24
+ def write_to_storage(content)
25
+ FileUtils.mkdir_p(item_path.dirname)
26
+ File.open(item_path, 'w') { |f| f.write content }
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,68 @@
1
+ module Hansolo::Providers::DefaultBehavior
2
+ module Solo
3
+
4
+ # `rsync` data bags to the node
5
+ def sync_data_bags
6
+ rsync_resource(:data_bags)
7
+ end
8
+
9
+ # `rsync` cookbooks to the node
10
+ def sync_cookbooks
11
+ rsync_resource(:cookbooks)
12
+ end
13
+
14
+ private
15
+
16
+ def rsync_resource(resource)
17
+ threads = hosts.map do |host|
18
+ Thread.new { call rsync.command(rsync_params(host, resource)) }
19
+ end
20
+
21
+ threads.map(&:join)
22
+ end
23
+
24
+ def rsync
25
+ Cocaine::CommandLine.new('rsync', rsync_options)
26
+ end
27
+
28
+ def rsync_options
29
+ "--delete -av -e \"#{ssh_options}\" :source :destination"
30
+ end
31
+
32
+ def ssh_options
33
+ if !bastion.nil?
34
+ "ssh -A -l :bastion_user #{Hansolo.ssh_options} :bastion_host ssh -l :user #{Hansolo.ssh_options} -p :port"
35
+ else
36
+ "ssh -l :user #{Hansolo.ssh_options} -p :port"
37
+ end
38
+ end
39
+
40
+ def rsync_params(host, content)
41
+ params = {
42
+ user: host.user,
43
+ ssh_options: Hansolo.ssh_options,
44
+ port: host.port.to_s,
45
+ source: source(content),
46
+ destination: destination(host, content)
47
+ }
48
+
49
+ if !bastion.nil?
50
+ params.merge!(
51
+ bastion_user: bastion.user,
52
+ bastion_port: bastion.port.to_s,
53
+ bastion_host: bastion.host
54
+ )
55
+ end
56
+
57
+ params
58
+ end
59
+
60
+ def source(content)
61
+ "#{Hansolo.send("#{content}_path").join(Hansolo.app)}/"
62
+ end
63
+
64
+ def destination(host, content)
65
+ "#{host.user}@#{host.host}:/tmp/#{content}"
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,21 @@
1
+ module Hansolo
2
+ module Providers
3
+ module DefaultBehavior
4
+ # Sets {Hansolo::Commands::Base#bastion}
5
+ # @return [URI, NilClass]
6
+ def determine_bastion
7
+ @bastion = case Hansolo.gateway
8
+ when String then URI.parse(Hansolo.gateway)
9
+ when URI then Hansolo.gateway
10
+ else raise ArgumentError, 'pass in a String or URI object'
11
+ end
12
+ end
13
+
14
+ # Builds an array of `URI` instances representing target nodes
15
+ # @return [Array<URI>]
16
+ def hosts
17
+ @hosts ||= Array(Hansolo.target).map { |target| URI.parse(target) }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -1,3 +1,3 @@
1
1
  module Hansolo
2
- VERSION = "0.0.1.alpha.3"
2
+ VERSION = "0.1.0"
3
3
  end
data/lib/hansolo.rb CHANGED
@@ -1,210 +1,46 @@
1
- require 'net/ssh'
2
- require 'json'
3
- require 'aws-sdk'
1
+ require 'logger'
2
+ require "hansolo/version"
3
+ require 'hansolo/librarians'
4
4
 
5
5
  module Hansolo
6
- class Cli
7
- attr_accessor :keydir, :urls, :runlist, :s3conn, :app, :stage, :aws_bucket_name, :aws_data_bag_keys
8
-
9
- def initialize(args={})
10
- @keydir = args[:keydir]
11
- @urls = args[:urls]
12
- @runlist = args[:runlist]
13
- @app = args[:app]
14
- @stage = args[:stage]
15
- @aws_bucket_name = args[:aws_bucket_name]
16
- @aws_data_bag_keys = args[:aws_data_bag_keys]
17
- @aws_secret_access_key = args[:aws_secret_access_key]
18
- @aws_access_key_id = args[:aws_access_key_id]
19
-
20
- if (@aws_secret_access_key && @aws_access_key_id && @aws_bucket_name && @aws_data_bag_keys)
21
- @s3conn = AWS::S3.new(:access_key_id => args[:aws_access_key_id],
22
- :secret_access_key => args[:aws_secret_access_key])
23
- end
24
- end
25
-
26
- def self.banner
27
- "Usage: hansolo [OPTS]"
28
- end
29
-
30
- def self.help
31
- DATA.read
32
- end
33
-
34
- def tmpdir
35
- '/tmp'
36
- end
37
-
38
- def all!
39
- vendor_berkshelf!
40
- rsync_cookbooks!
41
- rsync_data_bags! if s3conn
42
- solo!
43
- end
44
-
45
- def username(url)
46
- @username ||= Util.parse_url(url)[:username]
47
- end
48
-
49
- def dest_cookbooks_dir(url)
50
- File.join("/", "home", username(url), "cookbooks")
51
- end
52
-
53
- def dest_data_bags_dir(url)
54
- File.join("/", "home", username(url), "data_bags")
55
- end
56
-
57
- def local_cookbooks_tmpdir
58
- File.join(tmpdir, 'cookbooks.working')
59
- end
60
-
61
- def local_data_bags_tmpdir
62
- File.join(tmpdir, 'data_bags.working')
63
- end
64
-
65
- def vendor_berkshelf!
66
- Util.call_vendor_berkshelf(local_cookbooks_tmpdir)
67
- end
68
-
69
- def s3_bucket
70
- s3_bucket = s3conn.buckets[aws_bucket_name]
71
- if s3_bucket.exists?
72
- s3_bucket
73
- else
74
- s3conn.buckets.create(aws_bucket_name)
75
- end
76
- end
77
-
78
- #def s3_key_name
79
- #"#{app}/#{stage}/environment.json"
80
- #end
81
-
82
- #def s3_item
83
- #s3_bucket.objects[s3_key_name]
84
- #end
85
-
86
- def rsync_cookbooks!
87
- raise ArgumentError, "missing urls array and keydir" unless (urls && keydir)
88
- urls.each do |url|
89
- opts = Util.parse_url(url).merge(keydir: keydir, sourcedir: local_cookbooks_tmpdir, destdir: dest_cookbooks_dir(url))
90
- Util.call_rsync(opts)
91
- end
92
- end
93
-
94
- def rsync_data_bags!
95
- # Grab JSON file from S3, and place it into a conventional place
96
- Util.call("mkdir -p #{File.join(local_data_bags_tmpdir, 'app')}")
97
-
98
- aws_data_bag_keys.each do |key_name|
99
- item = s3_bucket.objects[key_name]
100
- base_key_name = File.basename(key_name)
101
- File.open(File.join(local_data_bags_tmpdir, 'app', base_key_name), 'w') do |f|
102
- f.write item.read
103
- end if item.exists?
104
- end
105
-
106
- urls.each do |url|
107
- opts = Util.parse_url(url).merge(keydir: keydir, sourcedir: local_data_bags_tmpdir, destdir: dest_data_bags_dir(url))
108
- Util.call_rsync(opts)
109
- end
110
- end
111
-
112
- def solo!
113
- raise ArgumentError, "missing urls array and keydir" unless (urls && keydir)
114
- urls.each { |url| Util.chef_solo(Util.parse_url(url).merge(keydir: keydir, cookbooks_dir: dest_cookbooks_dir(url), data_bags_dir: dest_data_bags_dir(url), runlist: runlist)) }
115
- end
6
+ class << self
7
+ attr_accessor :keydir,
8
+ :gateway,
9
+ :app,
10
+ :target,
11
+ :runlist,
12
+ :cookbooks_path,
13
+ :data_bags_path,
14
+ :post_ssh_command,
15
+ :librarian,
16
+ :ssh_options
116
17
  end
117
18
 
118
- module Util
119
- def self.call(cmd)
120
- puts "* #{cmd}"
121
- %x{#{cmd}}
122
- end
123
-
124
- def self.call_vendor_berkshelf(tmpdir)
125
- call("rm -rf #{tmpdir} && bundle exec berks install --path #{tmpdir}")
126
- end
127
-
128
- def self.call_rsync(args={})
129
- cmd = "rsync -av -e 'ssh -l #{args[:username]} #{ssh_options(["-p #{args[:port]}", "-i #{args[:keydir]}"])}' "
130
- cmd << "#{args[:sourcedir]}/ #{args[:username]}@#{args[:hostname]}:#{args[:destdir]}"
131
- call cmd
132
- end
133
-
134
- def self.chef_solo(args={})
135
- # on remote do:
136
- # build a solo.rb
137
- # build a tmp json file with the contents { "run_list": [ "recipe[my_app::default]" ] }
138
- # chef-solo -c solo.rb -j tmp.json
19
+ LOGGER = Logger.new(STDOUT)
20
+ LOGGER.formatter = proc do |severity, datetime, progname, msg|
21
+ "* #{msg}\n"
22
+ end
139
23
 
140
- Net::SSH.start(args[:hostname], args[:username], :port => args[:port], :keys => [ args[:keydir] ]) do |ssh|
141
- puts ssh.exec! "echo \"#{solo_rb(args[:tmpdir], args[:cookbooks_dir], args[:data_bags_dir])}\" > /tmp/solo.rb"
142
- puts ssh.exec! "echo '#{ { :run_list => args[:runlist] }.to_json }' > /tmp/deploy.json"
143
- ssh.exec! 'PATH="$PATH:/opt/vagrant_ruby/bin" sudo chef-solo -l debug -c /tmp/solo.rb -j /tmp/deploy.json' do |ch, stream, line|
144
- puts line
145
- end
146
- end
147
- end
24
+ def self.configure
25
+ yield self
148
26
 
149
- private
27
+ self.cookbooks_path ||= Pathname.new('tmp/cookbooks')
28
+ self.data_bags_path ||= Pathname.new('tmp/data_bags')
29
+ self.ssh_options ||= '-q -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no'
30
+ end
150
31
 
151
- def self.solo_rb(tmpdir, cookbooks_dir, data_bags_dir)
152
- [
153
- "file_cache_path '#{tmpdir}'",
154
- "cookbook_path '#{cookbooks_dir}'",
155
- "data_bag_path '#{data_bags_dir}'"
156
- ].join("\n")
157
- end
32
+ def self.logger
33
+ LOGGER
34
+ end
158
35
 
159
- def self.parse_url(url)
160
- if (url =~ /^([^\@]*)@([^:]*):([0-9]*)$/)
161
- return { username: $1, hostname: $2, port: $3.to_i }
162
- else
163
- raise ArgumentError, "Unable to parse `#{url}', should be in form `user@host:port'"
164
- end
165
- end
36
+ def self.librarians
37
+ {
38
+ berkshelf: Librarians::Berkshelf
39
+ }
40
+ end
41
+ private_class_method :librarians
166
42
 
167
- def self.ssh_options(opts=[])
168
- (
169
- [
170
- "-q",
171
- "-o StrictHostKeyChecking=no",
172
- "-o UserKnownHostsFile=/dev/null"
173
- ] + opts
174
- ).join(' ')
175
- end
43
+ def self.librarian=(librarian)
44
+ @librarian = librarians[librarian]
176
45
  end
177
46
  end
178
-
179
- require "hansolo/version"
180
-
181
- __END__
182
- This is a simple cli program to automate deploy using chef-solo and
183
- berkshelf.
184
-
185
- If you pass a filename, put in JSON for the configuration. So in .hansolo.json:
186
-
187
- { "keydir": "/Applications/Vagrant/embedded/gems/gems/vagrant-1.1.4/keys/vagrant" }
188
-
189
- Then you can pass to the command as:
190
-
191
- $ hansolo -c .hansolo.json
192
-
193
- NOTE: Command-line args trump config settings.
194
-
195
- Example Usage:
196
-
197
- $ hansolo -s approval -t /tmp/myapp.cookbooks \
198
-
199
- -k /Applications/Vagrant/embedded/gems/gems/vagrant-1.1.4/keys/vagrant \
200
-
201
- -u user@host1:22/path,user@host2:22/path \
202
-
203
- -r apt::default,myapp::deploy
204
-
205
- $ hansolo -s approval -c .hansolo.json
206
-
207
- $ hansolo -s approval
208
-
209
- NOTE: You don't need to pass -c if you use the filename .hansolo.json. Passing -c
210
- will override reading this default.
metadata CHANGED
@@ -1,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hansolo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1.alpha.3
5
- prerelease: 6
4
+ version: 0.1.0
5
+ prerelease:
6
6
  platform: ruby
7
7
  authors:
8
8
  - Brian Kaney
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2013-04-12 00:00:00.000000000 Z
12
+ date: 2013-07-09 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: aws-sdk
@@ -43,6 +43,22 @@ dependencies:
43
43
  - - ! '>='
44
44
  - !ruby/object:Gem::Version
45
45
  version: '0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: net-ssh-gateway
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
46
62
  - !ruby/object:Gem::Dependency
47
63
  name: json
48
64
  requirement: !ruby/object:Gem::Requirement
@@ -59,6 +75,38 @@ dependencies:
59
75
  - - ! '>='
60
76
  - !ruby/object:Gem::Version
61
77
  version: '0'
78
+ - !ruby/object:Gem::Dependency
79
+ name: terminal-table
80
+ requirement: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ! '>='
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ type: :runtime
87
+ prerelease: false
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ! '>='
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ - !ruby/object:Gem::Dependency
95
+ name: cocaine
96
+ requirement: !ruby/object:Gem::Requirement
97
+ none: false
98
+ requirements:
99
+ - - ! '>='
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ type: :runtime
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ! '>='
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
62
110
  - !ruby/object:Gem::Dependency
63
111
  name: bundler
64
112
  requirement: !ruby/object:Gem::Requirement
@@ -107,22 +155,72 @@ dependencies:
107
155
  - - ! '>='
108
156
  - !ruby/object:Gem::Version
109
157
  version: '0'
158
+ - !ruby/object:Gem::Dependency
159
+ name: yard
160
+ requirement: !ruby/object:Gem::Requirement
161
+ none: false
162
+ requirements:
163
+ - - ! '>='
164
+ - !ruby/object:Gem::Version
165
+ version: '0'
166
+ type: :development
167
+ prerelease: false
168
+ version_requirements: !ruby/object:Gem::Requirement
169
+ none: false
170
+ requirements:
171
+ - - ! '>='
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ - !ruby/object:Gem::Dependency
175
+ name: redcarpet
176
+ requirement: !ruby/object:Gem::Requirement
177
+ none: false
178
+ requirements:
179
+ - - ! '>='
180
+ - !ruby/object:Gem::Version
181
+ version: '0'
182
+ type: :development
183
+ prerelease: false
184
+ version_requirements: !ruby/object:Gem::Requirement
185
+ none: false
186
+ requirements:
187
+ - - ! '>='
188
+ - !ruby/object:Gem::Version
189
+ version: '0'
110
190
  description: Tool to automate deployment using chef-solo and berkshelf
111
191
  email:
112
192
  - brian@vermonster.com
113
193
  executables:
114
194
  - hansolo
195
+ - hansolo-databag
196
+ - hansolo-ssh
115
197
  extensions: []
116
198
  extra_rdoc_files: []
117
199
  files:
118
200
  - .gitignore
201
+ - .yardopts
119
202
  - Gemfile
120
203
  - LICENSE.txt
121
204
  - README.md
122
205
  - Rakefile
123
206
  - bin/hansolo
207
+ - bin/hansolo-databag
208
+ - bin/hansolo-ssh
124
209
  - hansolo.gemspec
125
210
  - lib/hansolo.rb
211
+ - lib/hansolo/commands/base.rb
212
+ - lib/hansolo/commands/data_bag.rb
213
+ - lib/hansolo/commands/solo.rb
214
+ - lib/hansolo/commands/ssh.rb
215
+ - lib/hansolo/librarians.rb
216
+ - lib/hansolo/librarians/berkshelf.rb
217
+ - lib/hansolo/providers/aws.rb
218
+ - lib/hansolo/providers/aws/data_bags.rb
219
+ - lib/hansolo/providers/aws/discovery.rb
220
+ - lib/hansolo/providers/aws/solo.rb
221
+ - lib/hansolo/providers/default.rb
222
+ - lib/hansolo/providers/default/data_bags.rb
223
+ - lib/hansolo/providers/default/solo.rb
126
224
  - lib/hansolo/version.rb
127
225
  - tests/hansolo_test.rb
128
226
  homepage: ''
@@ -141,9 +239,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
141
239
  required_rubygems_version: !ruby/object:Gem::Requirement
142
240
  none: false
143
241
  requirements:
144
- - - ! '>'
242
+ - - ! '>='
145
243
  - !ruby/object:Gem::Version
146
- version: 1.3.1
244
+ version: '0'
147
245
  requirements: []
148
246
  rubyforge_project:
149
247
  rubygems_version: 1.8.23