knife-instance 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/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/.travis.yml ADDED
@@ -0,0 +1,3 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.3
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in knife-instance.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2013 ZestFinance
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # knife-instance [![Code Climate](https://codeclimate.com/github/ZestFinance/knife-instance.png)](https://codeclimate.com/github/ZestFinance/knife-instance) [![Build Status](https://travis-ci.org/ZestFinance/knife-instance.png)](https://travis-ci.org/ZestFinance/knife-instance)
2
+
3
+ Manage EC2 instances with Chef from the command line
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'knife-instance'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install knife-instance
18
+
19
+ ## Configuration
20
+
21
+ Add this to your environment ~/.bashrc or ~/.zshrc
22
+
23
+ ```shell
24
+ export aws_access_key_id='Put your key here'
25
+ export aws_secret_access_key='Put your key here'
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```shell
31
+ bundle exec knife instance create -E development \
32
+ --image my_ec2-image \
33
+ -t myclustertag \
34
+ --group my_security_group
35
+ ```
36
+
37
+ ## Contributing
38
+
39
+ 1. Fork it ( http://github.com/ZestFinance/knife-instance/fork )
40
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
41
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
42
+ 4. Push to the branch (`git push origin my-new-feature`)
43
+ 5. Create new Pull Request
44
+
45
+ ## Credits
46
+ Inspired by [knife-ec2 gem](https://github.com/opscode/knife-ec2)
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ require "bundler/gem_tasks"
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec) do |t|
5
+ t.rspec_opts = '--color'
6
+ t.pattern = 'spec/**/*_spec.rb'
7
+ end
8
+
9
+ task default: :spec
@@ -0,0 +1,26 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'knife-instance/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "knife-instance"
8
+ spec.version = Knife::Instance::VERSION
9
+ spec.authors = ["Alexander Tamoykin", "Val Brodsky"]
10
+ spec.email = ["at@zestfinance.com", "vlb@zestfinance.com"]
11
+ spec.summary = %q{Manage EC2 instances with Chef from the command line}
12
+ spec.description = %q{Manage EC2 instances with Chef from the command line}
13
+ spec.homepage = "https://github.com/ZestFinance/knife-instance"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency 'chef', '>= 0.10.24'
22
+ spec.add_dependency 'fog', '>= 1.9.0'
23
+ spec.add_development_dependency "rake"
24
+ spec.add_development_dependency "rspec"
25
+ spec.add_development_dependency "require_all"
26
+ end
@@ -0,0 +1,202 @@
1
+ require "knife-instance/zestknife"
2
+
3
+ class Chef
4
+ class Knife
5
+ class InstanceCreate < ::ZestKnife
6
+ banner "knife instance create (options)"
7
+
8
+ deps do
9
+ require 'fog'
10
+ require 'fog/aws/models/dns/record'
11
+ require 'readline'
12
+ require 'chef/json_compat'
13
+ require 'chef/node'
14
+ require 'chef/api_client'
15
+ end
16
+
17
+ attr_accessor :hostname
18
+
19
+ with_opts :aws_ssh_key_id, :encrypted_data_bag_secret, :wait_for_it
20
+ with_validated_opts :cluster_tag, :environment, :base_domain, :region
21
+
22
+ option :flavor,
23
+ :short => "-f FLAVOR",
24
+ :long => "--flavor FLAVOR",
25
+ :description => "The flavor of server (m1.small, m1.medium, etc)",
26
+ :default => "m1.small"
27
+
28
+ option :image,
29
+ :short => "-I IMAGE",
30
+ :long => "--image IMAGE",
31
+ :description => "The AMI for the server"
32
+
33
+ option :iam_role,
34
+ :long => "--iam-role IAM_ROLE",
35
+ :description => "Assign a node to an IAM Role. Set to 'default_role' by default",
36
+ :default => 'default_role'
37
+
38
+ option :security_groups,
39
+ :short => "-G X,Y,Z",
40
+ :long => "--groups X,Y,Z",
41
+ :description => "The security groups for this server"
42
+
43
+ option :availability_zone,
44
+ :short => "-Z ZONE",
45
+ :long => "--availability-zone ZONE",
46
+ :description => "The Availability Zone"
47
+
48
+ option :hostname,
49
+ :short => "-h NAME",
50
+ :long => "--node-name NAME",
51
+ :description => "The Chef node name for your new node"
52
+
53
+ option :run_list,
54
+ :short => "-r RUN_LIST",
55
+ :long => "--run-list RUN_LIST",
56
+ :description => "(Required) Comma separated list of roles/recipes to apply",
57
+ :default => ["role[base]"],
58
+ :proc => lambda { |o| o.split(/[\s,]+/) }
59
+
60
+ option :show_server_options,
61
+ :short => "-D",
62
+ :long => "--server-dry-run",
63
+ :description => "Show the options used to create the server and exit before running",
64
+ :boolean => true,
65
+ :default => false
66
+
67
+ def run
68
+ $stdout.sync = true
69
+ setup_config
70
+
71
+ @environment = config[:environment]
72
+ @base_domain = config[:base_domain]
73
+ @hostname = config[:hostname] || generate_hostname(@environment)
74
+ @color = config[:cluster_tag]
75
+ @region = config[:region]
76
+
77
+ validate!
78
+
79
+ get_user_data
80
+
81
+ if config[:show_server_options]
82
+ details = create_server_def
83
+ ui.info(
84
+ ui.color("Creating server with options\n", :bold) +
85
+ ui.color(JSON.pretty_generate(details.reject { |k, v| k == :user_data }), :blue) +
86
+ ui.color("\nWith user script\n", :bold) +
87
+ ui.color(details[:user_data], :cyan)
88
+ )
89
+ exit 0
90
+ end
91
+
92
+ server = ZestKnife.aws_for_region(@region).compute.servers.create(create_server_def)
93
+
94
+ msg_pair("Zest Hostname", fqdn(hostname))
95
+ msg_pair("Environment", @environment)
96
+ msg_pair("Run List", config[:run_list].join(', '))
97
+ msg_pair("Instance ID", server.id)
98
+ msg_pair("Flavor", server.flavor_id)
99
+ msg_pair("Image", server.image_id)
100
+ msg_pair("Region", @region)
101
+ msg_pair("Availability Zone", server.availability_zone)
102
+ msg_pair("Security Groups", server.groups.join(", "))
103
+ msg_pair("SSH Key", server.key_name)
104
+
105
+ return unless config[:wait_for_it]
106
+
107
+ print "\n#{ui.color("Waiting for server", :magenta)}"
108
+
109
+ # wait for it to be ready to do stuff
110
+ server.wait_for { print "."; ready? }
111
+
112
+ puts "\n"
113
+ msg_pair("Public DNS Name", server.dns_name)
114
+ msg_pair("Public IP Address", server.public_ip_address)
115
+ msg_pair("Private DNS Name", server.private_dns_name)
116
+ msg_pair("Private IP Address", server.private_ip_address)
117
+ msg_pair("Instance ID", server.id)
118
+ msg_pair("Flavor", server.flavor_id)
119
+ msg_pair("Image", server.image_id)
120
+ msg_pair("Region", @region)
121
+ msg_pair("Availability Zone", server.availability_zone)
122
+ msg_pair("Security Groups", server.groups.join(", "))
123
+ msg_pair("SSH Key", server.key_name)
124
+ msg_pair("Root Device Type", server.root_device_type)
125
+
126
+ zone.records.create(:name => fqdn(hostname), :type => 'A', :value => server.private_ip_address, :ttl => 300)
127
+
128
+ server
129
+ end
130
+
131
+ def self.new_with_defaults environment, region, color, base_domain, opts
132
+ new.tap do |ic|
133
+ ic.config[:environment] = environment
134
+ ic.config[:cluster_tag] = color
135
+ ic.config[:region] = region
136
+ ic.config[:base_domain] = base_domain
137
+ ic.config[:aws_ssh_key_id] = opts[:aws_ssh_key_id]
138
+ ic.config[:aws_access_key_id] = opts[:aws_access_key_id]
139
+ ic.config[:aws_secret_access_key] = opts[:aws_secret_access_key]
140
+ ic.config[:availability_zone] = opts[:availability_zone]
141
+ ic.config[:encrypted_data_bag_secret] = opts[:encrypted_data_bag_secret]
142
+ ic.config[:wait_for_it] = opts[:wait_for_it]
143
+ ic.config[:image] = opts[:image]
144
+ end
145
+ end
146
+
147
+ def create_server_def
148
+ server_def = {
149
+ :image_id => image,
150
+ :groups => security_group,
151
+ :flavor_id => config[:flavor],
152
+ :key_name => config[:aws_ssh_key_id],
153
+ :availability_zone => availability_zone,
154
+ :tags => {
155
+ 'Name' => hostname,
156
+ 'environment' => @environment
157
+ },
158
+ :user_data => config[:without_user_data] ? "" : get_user_data,
159
+ :iam_instance_profile_name => config[:iam_role]
160
+ }
161
+
162
+ server_def
163
+ end
164
+
165
+ def image
166
+ config[:image]
167
+ end
168
+
169
+ def security_group
170
+ config[:security_groups]
171
+ end
172
+
173
+ def availability_zone
174
+ config[:availability_zone]
175
+ end
176
+
177
+ def ami
178
+ @ami ||= ZestKnife.aws_for_region(@region).compute.images.get(image)
179
+ end
180
+
181
+ def validate!
182
+ unless File.exists?(config[:encrypted_data_bag_secret])
183
+ errors << "Could not find encrypted data bag secret. Tried #{config[:encrypted_data_bag_secret]}"
184
+ end
185
+
186
+ if ami.nil?
187
+ errors << "You have not provided a valid image. Tried to find '#{image}'."
188
+ end
189
+
190
+ validate_hostname hostname
191
+
192
+ super([:aws_access_key_id, :aws_secret_access_key,
193
+ :flavor, :aws_ssh_key_id, :run_list])
194
+ end
195
+
196
+ def get_user_data
197
+ generator = Zest::BootstrapGenerator.new(Chef::Config[:validation_key], Chef::Config[:validation_client_name], Chef::Config[:chef_server_url], @environment, config[:run_list], hostname, @color, @base_domain, config[:encrypted_data_bag_secret])
198
+ generator.generate
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,2 @@
1
+ require 'knife-instance'
2
+ require 'chef/knife/instance_create'
@@ -0,0 +1,37 @@
1
+ require 'fog'
2
+ Excon.defaults[:ssl_verify_peer] = false
3
+
4
+ module Zest
5
+ class AWS
6
+ attr_accessor :aws_access_key_id, :aws_secret_access_key, :region
7
+
8
+ def initialize aws_access_key_id, aws_secret_access_key, region
9
+ @aws_access_key_id, @aws_secret_access_key, @region = aws_access_key_id, aws_secret_access_key, region
10
+ end
11
+
12
+ def compute
13
+ @compute ||= begin
14
+ Fog::Compute.new(
15
+ :provider => 'AWS',
16
+ :aws_access_key_id => aws_access_key_id,
17
+ :aws_secret_access_key => aws_secret_access_key,
18
+ :region => region
19
+ )
20
+ end
21
+ end
22
+
23
+ def servers
24
+ @servers ||= compute.servers
25
+ end
26
+
27
+ def dns
28
+ @dns ||= begin
29
+ Fog::DNS.new(
30
+ :provider => 'AWS',
31
+ :aws_access_key_id => aws_access_key_id,
32
+ :aws_secret_access_key => aws_secret_access_key
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,59 @@
1
+ # This class generates the ec2 user-data bootstrap script
2
+ module Zest
3
+ class BootstrapGenerator
4
+ CONFIG_FILE_TEMPLATE = File.expand_path 'templates/boot.sh.erb', File.dirname(__FILE__)
5
+
6
+ def initialize(validation_key_file, validation_client_name, chef_server_url, environment, run_list, hostname, color, base_domain, encrypted_databag_secret_file)
7
+ @validation_client_name = validation_client_name
8
+ @validation_key_file = validation_key_file
9
+ @chef_server_url = chef_server_url
10
+ @environment = environment
11
+ @run_list = run_list
12
+ @hostname = hostname
13
+ @color = color
14
+ @base_domain = base_domain
15
+ @encrypted_databag_secret_file = encrypted_databag_secret_file
16
+ end
17
+
18
+ def first_boot
19
+ {
20
+ "run_list" => @run_list,
21
+ "assigned_hostname" => @hostname,
22
+ "rails" => {"cluster" => {"color" => @color}},
23
+ "base_domain" => @base_domain
24
+ }.to_json
25
+ end
26
+
27
+ def validation_key
28
+ File.read(@validation_key_file)
29
+ end
30
+
31
+ def encrypted_data_bag_secret
32
+ File.read @encrypted_databag_secret_file
33
+ end
34
+
35
+ def generate
36
+ template = File.read(CONFIG_FILE_TEMPLATE)
37
+ Erubis::Eruby.new(template).evaluate(self)
38
+ end
39
+
40
+ def config_content
41
+ <<-CONFIG
42
+ require 'syslog-logger'
43
+ Logger::Syslog.class_eval do
44
+ attr_accessor :sync, :formatter
45
+ end
46
+
47
+ log_level :info
48
+ log_location Logger::Syslog.new("chef-client")
49
+ chef_server_url "#{@chef_server_url}"
50
+ validation_client_name "#{@validation_client_name}"
51
+ node_name "#{@hostname}"
52
+ CONFIG
53
+ end
54
+
55
+ def start_chef
56
+ "/usr/bin/chef-client -j /etc/chef/first-boot.json -E #{@environment}"
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,55 @@
1
+ #!/bin/bash
2
+ exec > >(tee /var/log/user-data.log|logger -t chef-client -s 2>/dev/console) 2>&1
3
+
4
+ set -e -x
5
+
6
+ if [ ! -f /usr/bin/chef-client ]; then
7
+ apt-get update
8
+ apt-get install -y ruby ruby1.8-dev build-essential wget libruby-extras libruby1.8-extras
9
+ cd /tmp
10
+ wget http://production.cf.rubygems.org/rubygems/rubygems-1.8.10.tgz
11
+ tar zxf rubygems-1.8.10.tgz
12
+ cd rubygems-1.8.10
13
+ ruby setup.rb --no-format-executable
14
+ cd ..
15
+ rm -Rf /tmp/rubygems-1.8.10
16
+ fi
17
+
18
+ gem update --no-rdoc --no-ri
19
+ gem install ohai --no-rdoc --no-ri --verbose
20
+ gem install mime-types --no-rdoc --no-ri --verbose --version 1.25
21
+ gem install chef --no-rdoc --no-ri --verbose --version 10.24.0
22
+ gem install tzinfo --no-rdoc --no-ri --verbose --version 0.3.33
23
+ gem install syslog-logger --no-rdoc --no-ri --verbose --version 1.6.8
24
+
25
+ mkdir -p /etc/chef
26
+
27
+ <%# Remember to suppress a newline at the end of an erb statement for keys --> -%>
28
+
29
+ (
30
+ cat <<'EOP'
31
+ <%= validation_key -%>
32
+ EOP
33
+ ) > /tmp/validation.pem
34
+ awk NF /tmp/validation.pem > /etc/chef/validation.pem
35
+ rm /tmp/validation.pem
36
+
37
+ (
38
+ cat <<'EOP'
39
+ <%= config_content %>
40
+ EOP
41
+ ) > /etc/chef/client.rb
42
+
43
+ (
44
+ cat <<'EOP'
45
+ <%= first_boot %>
46
+ EOP
47
+ ) > /etc/chef/first-boot.json
48
+
49
+ (
50
+ cat <<'EOP'
51
+ <%= encrypted_data_bag_secret -%>
52
+ EOP
53
+ ) > /etc/chef/encrypted_data_bag_secret
54
+
55
+ <%= start_chef %>
@@ -0,0 +1,5 @@
1
+ module Knife
2
+ module Instance
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,278 @@
1
+ require 'chef/knife'
2
+ require 'knife-instance/aws'
3
+ require 'knife-instance/bootstrap_generator'
4
+
5
+ class ZestKnife < Chef::Knife
6
+ attr_accessor :base_domain
7
+ attr_accessor :internal_domain
8
+
9
+ def msg_pair(label, value, color=:cyan)
10
+ if value && !value.to_s.empty?
11
+ puts "#{ui.color(label, color)}: #{value}"
12
+ end
13
+ end
14
+
15
+ def self.aws_for_region(region)
16
+ Zest::AWS.new(Chef::Config[:knife][:aws_access_key_id], Chef::Config[:knife][:aws_secret_access_key], region)
17
+ end
18
+
19
+ def self.AWS_REGIONS
20
+ []
21
+ end
22
+
23
+ def self.in_all_aws_regions
24
+ self.AWS_REGIONS.each do |region|
25
+ yield self.aws_for_region(region)
26
+ end
27
+ end
28
+
29
+ def find_item(klass, name)
30
+ begin
31
+ object = klass.load(name)
32
+ return [object]
33
+ rescue Net::HTTPServerException
34
+ return []
35
+ end
36
+ end
37
+
38
+ def find_ec2(name)
39
+ nodes = {}
40
+ self.class.in_all_aws_regions do |zest_aws|
41
+ nodes = nodes.merge(zest_aws.compute.servers.group_by { |s| s.tags["Name"] })
42
+ end
43
+ nodes[name].nil? ? [] : nodes[name]
44
+ end
45
+
46
+ def find_r53 name
47
+ in_zone = zone_from_name(name)
48
+ if in_zone.nil?
49
+ in_zone = zone
50
+ name = fqdn(name)
51
+ end
52
+ name = "#{name}." unless name[-1] == "."
53
+ recs = in_zone.records.select {|r| r.name == name }.to_a
54
+ recs.empty? ? [in_zone.records.get(name)].compact : recs
55
+ end
56
+
57
+ def zone
58
+ unless @zone
59
+ self.class.in_all_aws_regions do |zest_aws|
60
+ @zone ||= zest_aws.dns.zones.detect { |z| z.domain.downcase == domain }
61
+ end
62
+ raise "Could not find DNS zone" unless @zone
63
+ end
64
+
65
+ @zone
66
+ end
67
+
68
+ def zone_from_name dns_name
69
+ name, tld = dns_name.split(".")[-2..-1]
70
+ if name && tld
71
+ dns_domain = "#{name}.#{tld}"
72
+ zone = nil
73
+
74
+ self.class.in_all_aws_regions do |zest_aws|
75
+ zone1 = zest_aws.dns.zones.select {|x| x.domain =~ /^#{dns_domain}/ }.first
76
+ zone = zone1 if zone1
77
+ end
78
+
79
+ zone
80
+ end
81
+ end
82
+
83
+ def fqdn(name)
84
+ return '' if name.nil? || name.empty?
85
+ "#{name}.#{domain}"
86
+ end
87
+
88
+ def domain
89
+ @internal_domain || ""
90
+ end
91
+
92
+ def generate_hostname env
93
+ name = nil
94
+
95
+ 5.times do |i|
96
+ name = random_hostname env
97
+ break if check_services(name).empty?
98
+
99
+ name = nil
100
+ srand # re-seed rand so we don't get stuck in a sequence
101
+ end
102
+
103
+ errors << "Unable to find available hostname in 5 tries" if name.nil?
104
+ name
105
+ end
106
+
107
+ def validate_hostname hostname
108
+ errors << "hostname can't be blank" and return if (hostname.nil? || hostname.empty?)
109
+ check_services(hostname).each do |service|
110
+ errors << "#{hostname} in #{service.class} already exists. Delete first."
111
+ end
112
+ end
113
+
114
+ def check_services hostname
115
+ find_item(Chef::Node, hostname) +
116
+ find_item(Chef::ApiClient, hostname) +
117
+ find_ec2(hostname) +
118
+ find_r53(hostname)
119
+ end
120
+
121
+ def random_hostname env
122
+ "#{domain_prefix}#{environment_prefix env}#{random_three_digit_number}"
123
+ end
124
+
125
+ def domain_prefix
126
+ base_domain[0]
127
+ end
128
+
129
+ def environment_prefix env
130
+ env[0]
131
+ end
132
+
133
+ def random_three_digit_number
134
+ sprintf("%03d", rand(1000))
135
+ end
136
+
137
+ def self.with_opts(*args)
138
+ invalid_args = args.select {|arg| !OPTS.keys.include? arg }
139
+ raise "Invalid option(s) passed to with_opts: #{invalid_args.join(", ")}" unless invalid_args.empty?
140
+
141
+ args.each do |arg|
142
+ option arg, OPTS[arg]
143
+ end
144
+ end
145
+
146
+ class << self; attr_accessor :validated_opts end
147
+
148
+ def self.with_validated_opts(*args)
149
+ with_opts(*args)
150
+ validates(*args)
151
+ end
152
+
153
+ def self.validates(*args)
154
+ raise "Invalid argument(s) passed to validates: #{args - VALIDATORS.keys}" unless (args - VALIDATORS.keys).empty?
155
+ self.validated_opts ||= []
156
+ self.validated_opts.concat args
157
+ end
158
+
159
+ def setup_config(keys=[:aws_access_key_id, :aws_secret_access_key])
160
+ keys.each do |k|
161
+ Chef::Config[:knife][k] = ENV[k.to_s] if Chef::Config[:knife][k].nil? && ENV[k.to_s]
162
+ end
163
+ end
164
+
165
+ def errors
166
+ @errors ||= []
167
+ end
168
+
169
+ def errors?
170
+ !errors.empty?
171
+ end
172
+
173
+ def validate!(keys=[:aws_access_key_id, :aws_secret_access_key])
174
+ keys.each do |k|
175
+ if Chef::Config[:knife][k].nil? & config[k].nil?
176
+ errors << "You did not provide a valid '#{k}' value."
177
+ end
178
+ end
179
+
180
+ self.class.validated_opts.each do |opt|
181
+ send VALIDATORS[opt]
182
+ end if self.class.validated_opts
183
+
184
+ if errors.each { |e| ui.error(e) }.any?
185
+ exit 1
186
+ end
187
+ end
188
+
189
+ def validate_env
190
+ end
191
+
192
+ def validate_domain
193
+ end
194
+
195
+ def validate_region
196
+ end
197
+
198
+ def validate_force_deploy
199
+ end
200
+
201
+ def validate_color
202
+ unless @color
203
+ errors << "You must provide a cluster_tag with the -t option"
204
+ end
205
+ end
206
+
207
+ def validate_prod
208
+ end
209
+
210
+ OPTS = {
211
+ :aws_access_key_id => {
212
+ :short => "-A ID",
213
+ :long => "--aws-access-key-id KEY",
214
+ :description => "Your AWS Access Key ID",
215
+ :proc => Proc.new { |key| Chef::Config[:knife][:aws_access_key_id] = key }
216
+ },
217
+ :aws_secret_access_key => {
218
+ :short => "-K SECRET",
219
+ :long => "--aws-secret-access-key SECRET",
220
+ :description => "Your AWS API Secret Access Key",
221
+ :proc => Proc.new { |key| Chef::Config[:knife][:aws_secret_access_key] = key }
222
+ },
223
+ :cluster_tag => {
224
+ :short => "-t TAG",
225
+ :long => "--cluster-tag TAG",
226
+ :description => "Tag that identifies this node as part of the <TAG> cluster"
227
+ },
228
+ :environment => {
229
+ :short => "-E CHEF_ENV",
230
+ :long => "--environment CHEF_ENV",
231
+ :description => "Chef environment"
232
+ },
233
+ :region => {
234
+ :long => "--region REGION",
235
+ :short => '-R REGION',
236
+ :description => "Your AWS region",
237
+ :default => ENV['AWS_REGION'],
238
+ :proc => Proc.new { |key| Chef::Config[:knife][:region] = key }
239
+ },
240
+ :encrypted_data_bag_secret => {
241
+ :short => "-B FILE",
242
+ :long => "--encrypted_data_bag_secret FILE",
243
+ :description => "Path to the secret key to unlock encrypted chef data bags",
244
+ :default => ENV['DATABAG_KEY_PATH']
245
+ },
246
+ :aws_ssh_key_id => {
247
+ :short => "-S KEY",
248
+ :long => "--aws-ssh-key KEY",
249
+ :description => "AWS EC2 SSH Key Pair Name",
250
+ :default => ENV["aws_ssh_key"]
251
+ },
252
+ :base_domain => {
253
+ :long => "--base-domain DOMAIN",
254
+ :description => "The domain to be used for this node.",
255
+ :default => ENV["default_base_domain"] || ""
256
+ },
257
+ :wait_for_it => {
258
+ :short => "-W",
259
+ :long => "--wait-for-it",
260
+ :description => "Wait for EC2 to return extended details about the host and register DNS",
261
+ :boolean => true,
262
+ :default => false
263
+ },
264
+ :prod => {
265
+ :long => "--prod",
266
+ :description => "If the environment for your command is production, you must also pass this parameter. This is to make it slightly harder to do something unintentionally to production."
267
+ }
268
+ }
269
+
270
+ VALIDATORS = {
271
+ :environment => :validate_env,
272
+ :base_domain => :validate_domain,
273
+ :cluster_tag => :validate_color,
274
+ :prod => :validate_prod,
275
+ :force_deploy => :validate_force_deploy,
276
+ :region => :validate_region
277
+ }
278
+ end
@@ -0,0 +1,95 @@
1
+ require 'spec_helper'
2
+
3
+ describe Chef::Knife::InstanceCreate do
4
+ let(:internal_domain) { 'internal.com.' }
5
+
6
+ before do
7
+ described_class::load_deps
8
+ @instance = described_class.new
9
+ @instance.merge_configs
10
+ @instance.internal_domain = internal_domain
11
+ #TODO: refactor
12
+ @instance.class.stub(:AWS_REGIONS).and_return(['some_region'])
13
+ @instance.config[:environment] = "development"
14
+ @instance.config[:cluster_tag] = "purplish"
15
+ @instance.config[:base_domain] = "example.com"
16
+ @instance.config[:hostname] = 'd999'
17
+
18
+ @compute = double
19
+ @dns = double
20
+ @zone = double
21
+ @records = double
22
+ @record = double
23
+ @servers = double
24
+ @server = double
25
+
26
+ @server_attribs = {
27
+ :id => 'i-12345',
28
+ :flavor_id => 'vanilla',
29
+ :image_id => 'ami-12345',
30
+ :availability_zone => 'available',
31
+ :key_name => 'my_ssh_key',
32
+ :groups => ['group1', 'group2'],
33
+ :dns_name => 'dns.name.com',
34
+ :ip_address => '1.1.1.1',
35
+ :private_dns_name => 'ip-1-1-1-1.ec2.internal',
36
+ :private_ip_address => '1.1.1.1',
37
+ :public_ip_address => '8.8.8.8',
38
+ :root_device_type => 'instance',
39
+ :environment => 'development',
40
+ :hostname => 'd999'
41
+ }
42
+
43
+ @server_attribs.each_pair do |attrib, value|
44
+ @server.stub(attrib).and_return(value)
45
+ end
46
+ end
47
+
48
+ describe "run" do
49
+ before do
50
+ Fog::Compute::AWS.stub(:new).and_return(@compute)
51
+ @compute.stub(:servers)
52
+ @compute.stub(:images).and_return(@images)
53
+ @zone.stub(:domain).and_return(internal_domain)
54
+ @instance.stub(:puts)
55
+ @instance.ui.stub(:error)
56
+ @instance.stub(:get_user_data)
57
+ @instance.stub(:ami).and_return(double)
58
+ Chef::Config[:knife][:hostname] = @server.hostname
59
+ @instance.stub(:find_ec2).and_return([])
60
+ @instance.stub(:find_item).and_return([])
61
+ @instance.stub(:find_r53).and_return([])
62
+ end
63
+
64
+ it "creates an EC2 instance and bootstraps it" do
65
+ @compute.should_receive(:servers).and_return(@servers)
66
+ @servers.should_receive(:create).and_return(@server)
67
+ @instance.run
68
+ end
69
+
70
+ it "waits for aws response and registers host with route 53" do
71
+ @compute.should_receive(:servers).and_return(@servers)
72
+ @servers.should_receive(:create).and_return(@server)
73
+ @instance.config[:wait_for_it] = true
74
+ @records.should_receive(:create).and_return(true)
75
+ @zone.should_receive(:records).and_return(@records)
76
+ @dns.should_receive(:zones).at_least(:once).and_return([@zone])
77
+ Fog::DNS::AWS.should_receive(:new).and_return(@dns)
78
+ @server.should_receive(:wait_for).and_return(true)
79
+ @instance.run
80
+ end
81
+
82
+ describe "#validate!" do
83
+ context "when there is ec2 host already" do
84
+ it "should exit with errors" do
85
+ @instance.should_receive(:find_ec2).and_return([double])
86
+ @instance.setup_config
87
+ @instance.config[:environment] = "development"
88
+ @instance.hostname = 'd999'
89
+ expect { @instance.validate! }.to raise_error SystemExit
90
+ @instance.errors.should include "d999 in RSpec::Mocks::Mock already exists. Delete first."
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,212 @@
1
+ require 'spec_helper'
2
+
3
+ describe ZestKnife do
4
+ subject { described_class.new }
5
+
6
+ before do
7
+ subject.stub(:domain).and_return('internal.com.')
8
+ subject.class.stub(:AWS_REGIONS).and_return(['some_region'])
9
+ end
10
+
11
+ describe "#fqdn" do
12
+ it { subject.fqdn('').should == '' }
13
+ it { subject.fqdn(nil).should == '' }
14
+ it "provides a valid fqdn" do
15
+ fqdn = subject.fqdn('d999')
16
+ fqdn.should == 'd999.internal.com.'
17
+ end
18
+ end
19
+
20
+ describe "#zone" do
21
+ let(:our_zone) { double('our dns zone', :domain => "internal.com.") }
22
+ let(:some_other_zone) { double('other dns zone', :domain => "zambocom.net.") }
23
+
24
+ before(:each) do
25
+ aws_double = double
26
+ Zest::AWS.stub(:new).and_return(aws_double)
27
+ dns_double = double
28
+ aws_double.stub(:dns).and_return(dns_double)
29
+ dns_double.stub(:zones).and_return([our_zone, some_other_zone])
30
+ end
31
+
32
+ its(:zone) { should == our_zone }
33
+ end
34
+
35
+ describe "#domain" do
36
+ its(:domain) { should match(/[a-z]+\.[a-z]+\./) }
37
+ it { subject.domain[-1].should == '.' }
38
+ end
39
+
40
+ describe "#errors?" do
41
+ it "should be true when there are any errors" do
42
+ subject.errors << "Hello"
43
+ subject.errors?.should be_true
44
+ end
45
+
46
+ it "should be false when there are no errors" do
47
+ subject.errors?.should be_false
48
+ end
49
+ end
50
+
51
+ describe "#setup_config" do
52
+ after { Chef::Config[:knife].delete :foo }
53
+
54
+ it "should find env var and add to Chef::Config" do
55
+ ENV["FOO"] = "bar"
56
+ subject.setup_config(['FOO'])
57
+ Chef::Config[:knife]['FOO'].should == "bar"
58
+ end
59
+
60
+ it "should prefer Chef::Config[:knife] options over ENV options" do
61
+ ENV["foo"] = "bar"
62
+ subject.setup_config(["foo"])
63
+ Chef::Config[:knife]["foo"].should == "bar"
64
+ end
65
+ end
66
+
67
+ describe "#validate!" do
68
+ before do
69
+ subject.ui.stub(:error)
70
+ end
71
+
72
+ it "should add validation errors to error list" do
73
+ lambda { subject.validate!([:foo]) }.should raise_error SystemExit
74
+ subject.errors.should have(1).error
75
+ end
76
+
77
+ it "should report existing errors" do
78
+ subject.errors << "Hello"
79
+ lambda { subject.validate!([]) }.should raise_error SystemExit
80
+ subject.errors.should have(1).error
81
+ end
82
+ end
83
+
84
+ describe "#errors" do
85
+ it "should not be assignable" do
86
+ lambda { subject.errors = ['some-new-errors'] }.should raise_error
87
+ end
88
+
89
+ it "should be appendable" do
90
+ subject.errors.should respond_to :<<
91
+ subject.errors << "Hello"
92
+ subject.errors[0].should == "Hello"
93
+ end
94
+ end
95
+
96
+ describe "#find_r53" do
97
+ let(:r53_cname_record) do
98
+ double('Fog::DNS::AWS::Record',
99
+ :class => 'Fog::DNS::AWS::Record',
100
+ :name => "d999.internal.com.",
101
+ :type => "CNAME",
102
+ :value => "127-1-1-1.compute-1.amazonaws.com")
103
+ end
104
+
105
+ before do
106
+ Fog::DNS.stub(:new) { double('fog dns client',
107
+ :zones => [double('fake dns zone',
108
+ :records => r53_records,
109
+ :domain => "internal.com.")])}
110
+ end
111
+
112
+ context "instance exists" do
113
+ let(:r53_records) { [r53_cname_record] }
114
+ it { subject.find_r53('d999').should == [ r53_cname_record ] }
115
+ end
116
+
117
+ context "there is no such instance" do
118
+ let(:r53_records) { double('Fake records', :select => [], :get => nil) }
119
+ it { subject.find_r53('d999').should == [] }
120
+ end
121
+ end
122
+
123
+ describe "#find_ec2" do
124
+ let(:ec2_servers) { [ec2_instance] }
125
+ let(:ec2_instance) do
126
+ double('Fog::Compute::AWS::Server',
127
+ :class => 'Fog::Compute::AWS::Server',
128
+ :id => 'my_ec2_instance',
129
+ :tags => {'Name' => 'd999'})
130
+ end
131
+
132
+ before do
133
+ Fog::Compute.stub(:new) { double('fog compute client', :servers => ec2_servers) }
134
+ end
135
+
136
+ context "instance exists" do
137
+ let(:ec2_servers) { [ec2_instance] }
138
+ it { subject.find_ec2('d999').should == [ ec2_instance ] }
139
+ end
140
+
141
+ context "there is no such instance" do
142
+ let(:ec2_servers) { [] }
143
+ it { subject.find_ec2('d999').should == [] }
144
+ end
145
+ end
146
+
147
+ describe "#find_item" do
148
+ let(:client) { double }
149
+
150
+ context "when class can be loaded" do
151
+ let(:remote_resource) { double }
152
+ before { client.should_receive(:load).with('my-name').and_return remote_resource }
153
+ it { subject.find_item(client, 'my-name').should == [remote_resource] }
154
+ end
155
+
156
+ context "when class cannot be loaded" do
157
+ let(:exception) { Net::HTTPServerException.new "example.com", 1234 }
158
+ before { client.should_receive(:load).with('my-name').and_raise exception }
159
+ it { subject.find_item(client, 'my-name').should == [] }
160
+ end
161
+ end
162
+
163
+ describe "#generate_hostname" do
164
+ before { subject.instance_variable_set :@base_domain, 'example.com' }
165
+
166
+ it "should generate a valid hostname" do
167
+ subject.should_receive(:check_services).exactly(2).times.and_return([])
168
+ subject.validate_hostname subject.generate_hostname("production")
169
+ subject.errors?.should be_false
170
+ end
171
+
172
+ it "should fail if the hostname is taken" do
173
+ subject.should_receive(:check_services).exactly(5).times.and_return(["fake_error"])
174
+ subject.generate_hostname "production"
175
+ subject.errors?.should be_true
176
+ end
177
+ end
178
+
179
+ describe "#validate_hostname" do
180
+ let(:hostname) { 'd123' }
181
+ before { subject.should_receive(:check_services).and_return(existing_services) }
182
+
183
+ context "there is no such a host" do
184
+ let(:existing_services) { [] }
185
+
186
+ it "is valid" do
187
+ subject.validate_hostname hostname
188
+ subject.errors?.should be_false
189
+ end
190
+ end
191
+
192
+ context "when the same host already exists" do
193
+ let(:existing_services) { [hostname] }
194
+
195
+ it "is not valid" do
196
+ subject.validate_hostname hostname
197
+ subject.errors?.should be_true
198
+ end
199
+ end
200
+ end
201
+
202
+ describe '#random_hostname' do
203
+ context 'environment is production' do
204
+ let(:env) { 'production' }
205
+ before { subject.stub(:base_domain).and_return('example.com') }
206
+
207
+ context 'external domain is example.com' do
208
+ it { subject.random_hostname(env).should =~ /^ep\d\d\d$/ }
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,18 @@
1
+ $:.unshift File.expand_path '../lib', File.dirname(__FILE__)
2
+ ENV['aws_access_key_id'] = '12345'
3
+ ENV['aws_secret_access_key'] = '12345'
4
+ ENV['aws_ssh_key'] = 'some_key'
5
+ ENV['DATABAG_KEY_PATH'] = File.expand_path 'support/databag.key', File.dirname(__FILE__)
6
+
7
+ Bundler.require :default, :development
8
+ require_all 'lib/chef'
9
+ require_all 'lib/knife-instance'
10
+ require 'fog'
11
+
12
+ Fog.mock!
13
+
14
+ RSpec.configure do |config|
15
+ config.after(:each) do
16
+ Chef::Config[:environment] = nil
17
+ end
18
+ end
File without changes
metadata ADDED
@@ -0,0 +1,156 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: knife-instance
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Alexander Tamoykin
9
+ - Val Brodsky
10
+ autorequire:
11
+ bindir: bin
12
+ cert_chain: []
13
+ date: 2013-12-17 00:00:00.000000000 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: chef
17
+ requirement: !ruby/object:Gem::Requirement
18
+ none: false
19
+ requirements:
20
+ - - ! '>='
21
+ - !ruby/object:Gem::Version
22
+ version: 0.10.24
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ none: false
27
+ requirements:
28
+ - - ! '>='
29
+ - !ruby/object:Gem::Version
30
+ version: 0.10.24
31
+ - !ruby/object:Gem::Dependency
32
+ name: fog
33
+ requirement: !ruby/object:Gem::Requirement
34
+ none: false
35
+ requirements:
36
+ - - ! '>='
37
+ - !ruby/object:Gem::Version
38
+ version: 1.9.0
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ none: false
43
+ requirements:
44
+ - - ! '>='
45
+ - !ruby/object:Gem::Version
46
+ version: 1.9.0
47
+ - !ruby/object:Gem::Dependency
48
+ name: rake
49
+ requirement: !ruby/object:Gem::Requirement
50
+ none: false
51
+ requirements:
52
+ - - ! '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ type: :development
56
+ prerelease: false
57
+ version_requirements: !ruby/object:Gem::Requirement
58
+ none: false
59
+ requirements:
60
+ - - ! '>='
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ - !ruby/object:Gem::Dependency
64
+ name: rspec
65
+ requirement: !ruby/object:Gem::Requirement
66
+ none: false
67
+ requirements:
68
+ - - ! '>='
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ type: :development
72
+ prerelease: false
73
+ version_requirements: !ruby/object:Gem::Requirement
74
+ none: false
75
+ requirements:
76
+ - - ! '>='
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ - !ruby/object:Gem::Dependency
80
+ name: require_all
81
+ requirement: !ruby/object:Gem::Requirement
82
+ none: false
83
+ requirements:
84
+ - - ! '>='
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ type: :development
88
+ prerelease: false
89
+ version_requirements: !ruby/object:Gem::Requirement
90
+ none: false
91
+ requirements:
92
+ - - ! '>='
93
+ - !ruby/object:Gem::Version
94
+ version: '0'
95
+ description: Manage EC2 instances with Chef from the command line
96
+ email:
97
+ - at@zestfinance.com
98
+ - vlb@zestfinance.com
99
+ executables: []
100
+ extensions: []
101
+ extra_rdoc_files: []
102
+ files:
103
+ - .gitignore
104
+ - .travis.yml
105
+ - Gemfile
106
+ - LICENSE
107
+ - README.md
108
+ - Rakefile
109
+ - knife-instance.gemspec
110
+ - lib/chef/knife/instance_create.rb
111
+ - lib/knife-instance.rb
112
+ - lib/knife-instance/aws.rb
113
+ - lib/knife-instance/bootstrap_generator.rb
114
+ - lib/knife-instance/templates/boot.sh.erb
115
+ - lib/knife-instance/version.rb
116
+ - lib/knife-instance/zestknife.rb
117
+ - spec/lib/chef/knife/instance_create_spec.rb
118
+ - spec/lib/knife-instance/zestknife_spec.rb
119
+ - spec/spec_helper.rb
120
+ - spec/support/databag.key
121
+ homepage: https://github.com/ZestFinance/knife-instance
122
+ licenses:
123
+ - MIT
124
+ post_install_message:
125
+ rdoc_options: []
126
+ require_paths:
127
+ - lib
128
+ required_ruby_version: !ruby/object:Gem::Requirement
129
+ none: false
130
+ requirements:
131
+ - - ! '>='
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ segments:
135
+ - 0
136
+ hash: -3631599552630895710
137
+ required_rubygems_version: !ruby/object:Gem::Requirement
138
+ none: false
139
+ requirements:
140
+ - - ! '>='
141
+ - !ruby/object:Gem::Version
142
+ version: '0'
143
+ segments:
144
+ - 0
145
+ hash: -3631599552630895710
146
+ requirements: []
147
+ rubyforge_project:
148
+ rubygems_version: 1.8.23
149
+ signing_key:
150
+ specification_version: 3
151
+ summary: Manage EC2 instances with Chef from the command line
152
+ test_files:
153
+ - spec/lib/chef/knife/instance_create_spec.rb
154
+ - spec/lib/knife-instance/zestknife_spec.rb
155
+ - spec/spec_helper.rb
156
+ - spec/support/databag.key