stemcell 0.2.9 → 0.4.4

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.
@@ -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