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