knife-instance 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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