stemcell 0.2.9 → 0.4.4
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +1 -0
- data/LICENSE.txt +2 -2
- data/README.md +24 -12
- data/bin/necrosis +60 -26
- data/bin/stemcell +23 -180
- data/examples/stemcell.json +26 -0
- data/examples/stemcellrc +21 -2
- data/lib/stemcell/command_line.rb +222 -0
- data/lib/stemcell/errors.rb +25 -0
- data/lib/stemcell/launcher.rb +258 -0
- data/lib/stemcell/log_tailer.rb +143 -0
- data/lib/stemcell/metadata_launcher.rb +108 -0
- data/lib/stemcell/metadata_source.rb +139 -0
- data/lib/stemcell/option_parser.rb +292 -0
- data/lib/stemcell/templates/bootstrap.sh.erb +40 -3
- data/lib/stemcell/utility/deep_merge.rb +13 -0
- data/lib/stemcell/utility.rb +1 -0
- data/lib/stemcell/version.rb +1 -1
- data/lib/stemcell.rb +8 -187
- data/stemcell.gemspec +22 -17
- metadata +107 -13
@@ -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
|