stemcell 0.2.9 → 0.4.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,25 @@
1
+ module Stemcell
2
+ # This is the class from which all stemcell errors descend.
3
+ class Error < StandardError; end
4
+
5
+ class NoTemplateError < Error; end
6
+ class TemplateParseError < Error; end
7
+ class RoleExpansionError < Error; end
8
+ class EmptyRoleError < Error; end
9
+
10
+ class UnknownBackingStoreError < Error
11
+ attr_reader :backing_store
12
+ def initialize(backing_store)
13
+ super "Unknown backing store: #{backing_store}"
14
+ @backing_store = backing_store
15
+ end
16
+ end
17
+
18
+ class MissingStemcellOptionError < Error
19
+ attr_reader :option
20
+ def initialize(option)
21
+ super "Missing option: #{option}"
22
+ @option = option
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,258 @@
1
+ require 'aws-sdk'
2
+ require 'logger'
3
+ require 'erb'
4
+
5
+ require "stemcell/version"
6
+ require "stemcell/option_parser"
7
+
8
+ module Stemcell
9
+ class Launcher
10
+
11
+ REQUIRED_OPTIONS = [
12
+ 'aws_access_key',
13
+ 'aws_secret_key',
14
+ 'region'
15
+ ]
16
+
17
+ REQUIRED_LAUNCH_PARAMETERS = [
18
+ 'chef_role',
19
+ 'chef_environment',
20
+ 'chef_data_bag_secret',
21
+ 'git_branch',
22
+ 'git_key',
23
+ 'git_origin',
24
+ 'key_name',
25
+ 'instance_type',
26
+ 'image_id',
27
+ 'security_groups',
28
+ 'availability_zone',
29
+ 'count'
30
+ ]
31
+
32
+ LAUNCH_PARAMETERS = [
33
+ 'chef_role',
34
+ 'chef_environment',
35
+ 'chef_data_bag_secret',
36
+ 'git_branch',
37
+ 'git_key',
38
+ 'git_origin',
39
+ 'key_name',
40
+ 'instance_type',
41
+ 'image_id',
42
+ 'availability_zone',
43
+ 'count',
44
+ 'security_groups',
45
+ 'tags',
46
+ 'iam_role',
47
+ 'ebs_optimized',
48
+ 'block_device_mappings',
49
+ 'ephemeral_devices',
50
+ 'placement_group'
51
+ ]
52
+
53
+ TEMPLATE_PATH = '../templates/bootstrap.sh.erb'
54
+ LAST_BOOTSTRAP_LINE = "Stemcell bootstrap finished successfully!"
55
+
56
+ def initialize(opts={})
57
+ @log = Logger.new(STDOUT)
58
+ @log.level = Logger::INFO unless ENV['DEBUG']
59
+ @log.debug "creating new stemcell object"
60
+ @log.debug "opts are #{opts.inspect}"
61
+
62
+ REQUIRED_OPTIONS.each do |req|
63
+ raise ArgumentError, "missing required param #{req}" unless opts[req]
64
+ instance_variable_set("@#{req}",opts[req])
65
+ end
66
+
67
+ @ec2_url = "ec2.#{@region}.amazonaws.com"
68
+ @timeout = 300
69
+ @start_time = Time.new
70
+
71
+ AWS.config({
72
+ :access_key_id => @aws_access_key,
73
+ :secret_access_key => @aws_secret_key})
74
+
75
+ @ec2 = AWS::EC2.new(:ec2_endpoint => @ec2_url)
76
+ end
77
+
78
+
79
+ def launch(opts={})
80
+ verify_required_options(opts, REQUIRED_LAUNCH_PARAMETERS)
81
+
82
+ # attempt to accept keys as file paths
83
+ opts['git_key'] = try_file(opts['git_key'])
84
+ opts['chef_data_bag_secret'] = try_file(opts['chef_data_bag_secret'])
85
+
86
+ # generate tags and merge in any that were specefied as inputs
87
+ tags = {
88
+ 'Name' => "#{opts['chef_role']}-#{opts['chef_environment']}",
89
+ 'Group' => "#{opts['chef_role']}-#{opts['chef_environment']}",
90
+ 'created_by' => ENV['USER'],
91
+ 'stemcell' => VERSION,
92
+ }
93
+ tags.merge!(opts['tags']) if opts['tags']
94
+
95
+ # generate launch options
96
+ launch_options = {
97
+ :image_id => opts['image_id'],
98
+ :security_groups => opts['security_groups'],
99
+ :user_data => opts['user_data'],
100
+ :instance_type => opts['instance_type'],
101
+ :key_name => opts['key_name'],
102
+ :count => opts['count'],
103
+ }
104
+
105
+ # specify availability zone (optional)
106
+ if opts['availability_zone']
107
+ launch_options[:availability_zone] = opts['availability_zone']
108
+ end
109
+
110
+ # specify IAM role (optional)
111
+ if opts['iam_role']
112
+ launch_options[:iam_instance_profile] = opts['iam_role']
113
+ end
114
+
115
+ # specify placement group (optional)
116
+ if opts['placement_group']
117
+ launch_options[:placement] = {
118
+ :group_name => opts['placement_group'],
119
+ }
120
+ end
121
+
122
+ # specify an EBS-optimized instance (optional)
123
+ launch_options[:ebs_optimized] = true if opts['ebs_optimized']
124
+
125
+ # specify raw block device mappings (optional)
126
+ if opts['block_device_mappings']
127
+ launch_options[:block_device_mappings] = opts['block_device_mappings']
128
+ end
129
+
130
+ # specify ephemeral block device mappings (optional)
131
+ if opts['ephemeral_devices']
132
+ launch_options[:block_device_mappings] ||= []
133
+ opts['ephemeral_devices'].each_with_index do |device,i|
134
+ launch_options[:block_device_mappings].push ({
135
+ :device_name => device,
136
+ :virtual_name => "ephemeral#{i}"
137
+ })
138
+ end
139
+ end
140
+
141
+ #
142
+
143
+ # generate user data script to bootstrap instance, include in launch
144
+ # options UNLESS we have manually set the user-data (ie. for ec2admin)
145
+ launch_options[:user_data] ||= render_template(opts)
146
+
147
+ # launch instances
148
+ instances = do_launch(launch_options)
149
+
150
+ # wait for aws to report instance stats
151
+ wait(instances)
152
+
153
+ # set tags on all instances launched
154
+ set_tags(instances, tags)
155
+
156
+ print_run_info(instances)
157
+ @log.info "launched instances successfully"
158
+ return instances
159
+ end
160
+
161
+ def find_instance(id)
162
+ return @ec2.instances[id]
163
+ end
164
+
165
+ def kill(instances,opts={})
166
+ return if instances.nil?
167
+ instances.each do |i|
168
+ begin
169
+ instance = find_instance(i)
170
+ @log.warn "Terminating instance #{instance.instance_id}"
171
+ instance.terminate
172
+ rescue AWS::EC2::Errors::InvalidInstanceID::NotFound => e
173
+ throw e unless opts[:ignore_not_found]
174
+ end
175
+ end
176
+ end
177
+
178
+ # this is made public for ec2admin usage
179
+ def render_template(opts={})
180
+ template_file_path = File.expand_path(TEMPLATE_PATH, __FILE__)
181
+ template_file = File.read(template_file_path)
182
+ erb_template = ERB.new(template_file)
183
+ last_bootstrap_line = LAST_BOOTSTRAP_LINE
184
+ generated_template = erb_template.result(binding)
185
+ @log.debug "genereated template is #{generated_template}"
186
+ return generated_template
187
+ end
188
+
189
+ private
190
+
191
+ def print_run_info(instances)
192
+ puts "\nhere is the info for what's launched:"
193
+ instances.each do |instance|
194
+ puts "\tinstance_id: #{instance.instance_id}"
195
+ puts "\tpublic ip: #{instance.public_ip_address}"
196
+ puts
197
+ end
198
+ puts "install logs will be in /var/log/init and /var/log/init.err"
199
+ end
200
+
201
+ def wait(instances)
202
+ @log.info "Waiting up to #{@timeout} seconds for #{instances.count} " \
203
+ "instance(s) (#{instances.inspect}):"
204
+
205
+ while true
206
+ sleep 5
207
+ if Time.now - @start_time > @timeout
208
+ kill(instances)
209
+ raise TimeoutError, "exceded timeout of #{@timeout}"
210
+ end
211
+
212
+ if instances.select{|i| i.status != :running }.empty?
213
+ break
214
+ end
215
+ end
216
+
217
+ @log.info "all instances in running state"
218
+ end
219
+
220
+ def verify_required_options(params,required_options)
221
+ @log.debug "params is #{params}"
222
+ @log.debug "required_options are #{required_options}"
223
+ required_options.each do |required|
224
+ unless params.include?(required)
225
+ raise ArgumentError, "you need to provide option #{required}"
226
+ end
227
+ end
228
+ end
229
+
230
+ def do_launch(opts={})
231
+ @log.debug "about to launch instance(s) with options #{opts}"
232
+ @log.info "launching instances"
233
+ instances = @ec2.instances.create(opts)
234
+ instances = [instances] unless Array === instances
235
+ instances.each do |instance|
236
+ @log.info "launched instance #{instance.instance_id}"
237
+ end
238
+ return instances
239
+ end
240
+
241
+ def set_tags(instances=[],tags)
242
+ @log.info "setting tags on instance(s)"
243
+ instances.each do |instance|
244
+ instance.tags.set(tags)
245
+ end
246
+ end
247
+
248
+ # attempt to accept keys as file paths
249
+ def try_file(opt="")
250
+ begin
251
+ return File.read(opt)
252
+ rescue Object => e
253
+ return opt
254
+ end
255
+ end
256
+
257
+ end
258
+ end
@@ -0,0 +1,143 @@
1
+ require 'socket'
2
+ require 'net/ssh'
3
+
4
+ module Stemcell
5
+ class LogTailer
6
+ attr_reader :hostname
7
+ attr_reader :username
8
+ attr_reader :ssh_port
9
+
10
+ attr_reader :finished
11
+ attr_reader :interrupted
12
+
13
+ # Don't wait more than two minutes
14
+ MAX_WAIT_FOR_SSH = 120
15
+
16
+ TAILING_COMMAND =
17
+ "while [ ! -s /var/log/init ]; " \
18
+ "do " \
19
+ "printf '.' 1>&2; " \
20
+ "sleep 1; " \
21
+ "done; " \
22
+ "echo ' /var/log/init exists!' 1>&2; " \
23
+ "exec tail -qf /var/log/init*"
24
+
25
+ def initialize(hostname, username, ssh_port=22)
26
+ @hostname = hostname
27
+ @username = username
28
+ @ssh_port = ssh_port
29
+
30
+ @finished = false
31
+ @interrupted = false
32
+ end
33
+
34
+ def run!
35
+ while_catching_interrupt do
36
+ return unless wait_for_ssh
37
+ tail_until_interrupt
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def wait_for_ssh
44
+ return if interrupted
45
+
46
+ print "Waiting for sshd..."
47
+
48
+ start_time = Time.now
49
+ banner = nil
50
+
51
+ loop do
52
+ print "."
53
+ begin
54
+ banner = get_ssh_banner
55
+ break
56
+ rescue SocketError,
57
+ IOError,
58
+ Errno::ECONNREFUSED,
59
+ Errno::ECONNRESET,
60
+ Errno::EHOSTUNREACH,
61
+ Errno::ENETUNREACH
62
+ sleep 5
63
+ rescue Errno::ETIMEDOUT,
64
+ Errno::EPERM
65
+ end
66
+
67
+ # Don't wait forever
68
+ if Time.now - start_time > MAX_WAIT_FOR_SSH
69
+ puts " TIMEOUT!".red
70
+ return false
71
+ end
72
+
73
+ if done?
74
+ puts " ABORT!".red
75
+ return false
76
+ end
77
+ end
78
+
79
+ puts " UP!".green
80
+ puts "Server responded with: #{banner.green}"
81
+ true
82
+ end
83
+
84
+ def tail_until_interrupt
85
+ return if interrupted
86
+
87
+ session = Net::SSH.start(hostname, username)
88
+
89
+ channel = session.open_channel do |ch|
90
+ ch.request_pty do |ch, success|
91
+ raise "Couldn't start a pseudo-tty!" unless success
92
+
93
+ ch.on_data do |ch, data|
94
+ STDOUT.print(data)
95
+ @finished = true if contains_last_line?(data)
96
+ end
97
+ ch.on_extended_data do |c, type, data|
98
+ STDERR.print(data)
99
+ end
100
+
101
+ ch.exec(TAILING_COMMAND)
102
+ end
103
+ end
104
+
105
+ session.loop(1) do
106
+ if done?
107
+ # Send an interrupt to kill the remote process
108
+ channel.send_data(Net::SSH::Connection::Term::VINTR)
109
+ channel.send_data "exit\n"
110
+ channel.eof!
111
+ channel.close
112
+ false
113
+ else
114
+ session.busy?
115
+ end
116
+ end
117
+
118
+ session.close
119
+ end
120
+
121
+ def done?
122
+ finished || interrupted
123
+ end
124
+
125
+ def get_ssh_banner
126
+ socket = TCPSocket.new(hostname, ssh_port)
127
+ IO.select([socket], nil, nil, 5) ? socket.gets : nil
128
+ ensure
129
+ socket.close if socket
130
+ end
131
+
132
+ def contains_last_line?(data)
133
+ data =~ /#{Launcher::LAST_BOOTSTRAP_LINE}/
134
+ end
135
+
136
+ def while_catching_interrupt
137
+ trap(:SIGINT) { @interrupted = true }
138
+ yield
139
+ ensure
140
+ trap(:SIGINT, nil)
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,108 @@
1
+ module Stemcell
2
+ class MetadataLauncher
3
+ attr_reader :chef_root
4
+ attr_accessor :interactive
5
+
6
+ attr_reader :source
7
+
8
+ def initialize(options={})
9
+ @chef_root = options[:chef_root]
10
+ @interactive = options.fetch(:interactive, false)
11
+
12
+ raise ArgumentError, "You must specify chef_root" unless chef_root
13
+
14
+ @source = MetadataSource.new(chef_root)
15
+ end
16
+
17
+ def run!(role, override_options={})
18
+ environment = expand_environment(override_options)
19
+ launch_options = determine_options(role, environment, override_options)
20
+
21
+ validate_options(launch_options)
22
+ describe_instance(launch_options)
23
+ invoke_launcher(launch_options)
24
+ end
25
+
26
+ def default_options
27
+ source.default_options
28
+ end
29
+
30
+ private
31
+
32
+ def expand_environment(override_opts)
33
+ override_opts['chef_environment'] ||
34
+ default_options['chef_environment']
35
+ end
36
+
37
+ def determine_options(role, environment, override_options)
38
+ # Initially assume that empty roles are not allowed
39
+ allow_empty = false
40
+ begin
41
+ return source.expand_role(
42
+ role,
43
+ environment,
44
+ override_options,
45
+ :allow_empty_roles => allow_empty)
46
+ rescue EmptyRoleError
47
+ warn_empty_role
48
+ allow_empty = true
49
+ retry
50
+ end
51
+ end
52
+
53
+ def validate_options(options={})
54
+ [ Launcher::REQUIRED_OPTIONS,
55
+ Launcher::REQUIRED_LAUNCH_PARAMETERS
56
+ ].flatten.each do |arg|
57
+ if options[arg].nil? or !options[arg]
58
+ raise Stemcell::MissingStemcellOptionError.new(arg)
59
+ end
60
+ end
61
+ end
62
+
63
+ def describe_instance(options={})
64
+ puts "\nYou're about to launch instance(s) with the following options:\n\n"
65
+
66
+ options.keys.sort.each do |key|
67
+ value = options[key]
68
+ next unless value
69
+ spaces = " " * (23 - key.length)
70
+ puts " #{key}#{spaces}#{value.to_s.green}"
71
+ end
72
+
73
+ if interactive
74
+ print "\nProceed? (y/N) "
75
+ confirm = $stdin.gets
76
+ exit unless confirm.chomp.downcase == 'y'
77
+ end
78
+
79
+ # One more new line to be pretty.
80
+ print "\n"
81
+ end
82
+
83
+ def warn_empty_role
84
+ warn "\nWARNING: This role contains no stemcell attributes.".yellow
85
+
86
+ if interactive
87
+ print "\nDo you want to launch it anyways? (y/N) "
88
+ confirm = $stdin.gets
89
+ exit unless confirm.chomp.downcase == 'y'
90
+ end
91
+ end
92
+
93
+ def invoke_launcher(options={})
94
+ launcher = Launcher.new({
95
+ 'aws_access_key' => options['aws_access_key'],
96
+ 'aws_secret_key' => options['aws_secret_key'],
97
+ 'region' => options['region'],
98
+ })
99
+ # Slice off just the options used for launching.
100
+ launch_options = {}
101
+ Launcher::LAUNCH_PARAMETERS.each do |a|
102
+ launch_options[a] = options[a]
103
+ end
104
+ # Create the instance from these options.
105
+ launcher.launch(launch_options)
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,139 @@
1
+ require 'chef'
2
+ require 'json'
3
+
4
+ module Stemcell
5
+ class MetadataSource
6
+ attr_reader :chef_root
7
+ attr_reader :default_options
8
+
9
+ DEFAULT_OPTIONS = {
10
+ 'chef_environment' => 'production',
11
+ 'git_branch' => 'production',
12
+ 'count' => 1
13
+ }
14
+
15
+ # Search for instance metadata in the following role attributes, with
16
+ # priority given to the keys at the head.
17
+ METADATA_ATTRIBUTES = [
18
+ :instance_metadata,
19
+ :stemcell
20
+ ]
21
+
22
+ def initialize(chef_root)
23
+ @chef_root = chef_root
24
+
25
+ raise ArgumentError, "You must specify a chef root" unless chef_root
26
+
27
+ template_options = read_template
28
+ @default_options = DEFAULT_OPTIONS.merge(template_options['defaults'])
29
+
30
+ @all_backing_store_options = template_options['backing_store']
31
+ @all_azs_by_region = template_options['availability_zones']
32
+ end
33
+
34
+ def expand_role(role, environment, override_options={}, options={})
35
+ raise ArgumentError, "Missing chef role" unless role
36
+ raise ArgumentError, "Missing chef environment" unless environment
37
+ allow_empty_roles = options.fetch(:allow_empty_roles, false)
38
+
39
+ role_options = expand_role_options(role, environment)
40
+ role_empty = role_options.nil? || role_options.empty?
41
+
42
+ raise EmptyRoleError if !allow_empty_roles && role_empty
43
+
44
+ backing_store_options =
45
+ expand_backing_store_options(
46
+ default_options,
47
+ role_options,
48
+ override_options
49
+ )
50
+
51
+ # Merge all the options together in priority order
52
+ merged_options = default_options.dup
53
+ merged_options.deep_merge!(backing_store_options)
54
+ merged_options.deep_merge!(role_options) if role_options
55
+ merged_options.deep_merge!(override_options)
56
+
57
+ # Add the AZ if not specified
58
+ if (region = merged_options['region'])
59
+ merged_options['availability_zone'] ||= random_az_in_region(region)
60
+ end
61
+
62
+ # The chef environment and role used to expand the runlist takes
63
+ # priority over all other options.
64
+ merged_options['chef_environment'] = environment
65
+ merged_options['chef_role'] = role
66
+
67
+ merged_options
68
+ end
69
+
70
+ private
71
+
72
+ def read_template
73
+ begin
74
+ template_path = File.join(chef_root, 'stemcell.json')
75
+ template_options = JSON.parse(IO.read(template_path))
76
+ rescue Errno::ENOENT
77
+ raise NoTemplateError
78
+ rescue => e
79
+ raise TemplateParseError, e.message
80
+ end
81
+
82
+ errors = []
83
+ unless template_options.include?('defaults')
84
+ errors << 'missing required section "defaults"; should be a hash containing default launch options'
85
+ end
86
+
87
+ if template_options['availability_zones'].nil?
88
+ errors << 'missing or empty section "availability zones"'
89
+ errors << '"availability_zones" should be a hash from region name => list of allowed zones in that region'
90
+ end
91
+
92
+ if template_options['backing_store'].nil? or template_options['backing_store'].empty?
93
+ errors << 'missing or empty section "backing_store"'
94
+ errors << '"backing_store" should be a hash from store type (like "ebs") => hash of options for that store'
95
+ end
96
+
97
+ unless errors.empty?
98
+ raise TemplateParseError, errors.join("; ")
99
+ end
100
+
101
+ return template_options
102
+ end
103
+
104
+ def expand_role_options(chef_role, chef_environment)
105
+ Chef::Config[:role_path] = File.join(chef_root, 'roles')
106
+ Chef::Config[:data_bag_path] = File.join(chef_root, 'data_bags')
107
+
108
+ run_list = Chef::RunList.new
109
+ run_list << "role[#{chef_role}]"
110
+
111
+ expansion = run_list.expand(chef_environment, 'disk')
112
+ raise RoleExpansionError if expansion.errors?
113
+
114
+ default_attrs = expansion.default_attrs
115
+ override_attrs = expansion.override_attrs
116
+
117
+ merged_attrs = default_attrs.merge(override_attrs)
118
+ METADATA_ATTRIBUTES.inject(nil) { |r, key| r || merged_attrs[key] }
119
+ end
120
+
121
+ def expand_backing_store_options(default_opts, role_opts, override_opts)
122
+ backing_store = override_opts['backing_store']
123
+ backing_store ||= role_opts.to_hash['backing_store'] if role_opts
124
+ backing_store ||= default_opts['backing_store']
125
+ backing_store ||= 'instance_store'
126
+
127
+ backing_store_options = @all_backing_store_options[backing_store]
128
+ if backing_store_options.nil?
129
+ raise Stemcell::UnknownBackingStoreError.new(backing_store)
130
+ end
131
+ backing_store_options
132
+ end
133
+
134
+ def random_az_in_region(region)
135
+ possible_azs = @all_azs_by_region[region] || []
136
+ possible_azs.sample
137
+ end
138
+ end
139
+ end