stack-kicker 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
data/lib/stack.rb CHANGED
@@ -1,212 +1,787 @@
1
- require 'pp'
2
- require 'fog'
1
+ #!/usr/bin/env ruby
2
+ # Copyright 2012 Hewlett-Packard Development Company, L.P.
3
+ # All Rights Reserved.
4
+ #
5
+ # Author: Simon McCartney <simon.mccartney@hp.com>
6
+ #
7
+ # Licensed under the Apache License, Version 2.0 (the "License"); you may
8
+ # not use this file except in compliance with the License. You may obtain
9
+ # a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing, software
14
+ # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
15
+ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
16
+ # License for the specific language governing permissions and limitations
17
+ # under the License.
18
+ #
19
+ require 'bundler'
20
+ require 'rubygems'
3
21
 
22
+ require 'base64'
23
+ require 'erb'
24
+ require 'openstack' # https://github.com/ruby-openstack/ruby-openstack
25
+ require 'json' # http://www.ruby-doc.org/stdlib-1.9.3/libdoc/json/rdoc/JSON.html
26
+ require 'tempfile'
27
+
28
+
29
+ #
30
+ # This really needs to be converted into a class....
31
+ #
4
32
  module Stack
5
33
 
6
- def Stack.load_config(configfile, stack)
7
- config_raw = File.read(configfile)
8
- eval(config_raw)
9
- config = StackConfig::Stacks[stack]
10
- config
34
+ # Shadow the global constant Logger with Stack::Logger
35
+ # (if you want access to the global constant, use ::Logger from inside the Stack module)
36
+ Logger = Logger.new(STDOUT)
37
+ Logger.level = ::Logger::INFO
38
+ Logger.datetime_format = "%Y-%m-%d %H:%M:%S"
39
+ Logger.formatter = proc do |severity, datetime, progname, msg|
40
+ "#{datetime} #{severity}: #{msg}\n"
11
41
  end
12
42
 
13
- def Stack.connect(config)
14
- connection = Fog::Compute.new({ :provider => config[:provider],
15
- :aws_access_key_id => config[:aws_access_key_id],
16
- :aws_secret_access_key => config[:aws_secret_access_key],
17
- :region => config[:region] })
18
- connection
43
+ # location of gem, where config[:gemhome]/lib contains our default cloud-init templates
44
+ @@gemhome = File.absolute_path(File.realpath(File.dirname(File.expand_path(__FILE__)) + '/..'))
45
+
46
+ # Methadone::CLILogger is a Class, Stack is still a module, so we can't include it
47
+ # so this is a QADH to propagate the log_level
48
+ def Stack.log_level(level)
49
+ Logger.debug { "Setting the Logger.level to #{level}" }
50
+ Logger.level = level
19
51
  end
20
52
 
21
- def Stack.populate_config(config)
22
- # build out the full config for each node, supplying defaults from the
23
- # global config if explicitly supplied
24
- config[:node_details] = Hash.new if config[:node_details].nil?
53
+ def Stack.show_stacks(stackfile = 'Stackfile')
54
+ # our local config file
55
+ config_raw = File.read(stackfile)
56
+ eval(config_raw)
25
57
 
26
- config[:roles].each do |role, role_details|
27
- fqdn = role.to_s + '.' + config[:dns_domain]
28
-
29
- config[:node_details][fqdn] = {
30
- # set the node details from the role, if not specified in the role, use the config global
31
- # (takes advantage of left to right evaluation of ||)
32
- :flavor_id => (role_details[:flavor_id] || config[:flavor_id]),
33
- :count => (role_details[:count] || 1),
34
- :publish_private_ip => (role_details[:publish_private_ip] || false),
35
- :dns_wildcard => (role_details[:dns_wildcard] || false)
36
- }
58
+ Logger.info { "Stacks:" }
59
+ StackConfig::Stacks.each do |name, details|
60
+ Logger.info { " #{name}" }
37
61
  end
38
62
  end
63
+
64
+ def Stack.show_stack(config)
65
+ # syntax_check is a light weight check that doesn't talk to OpenStalk
66
+ Stack.syntax_check(config)
67
+ # generate an array of hostnames that this stack would create
68
+ hostnames = Stack.generate_server_names(config)
39
69
 
40
- def Stack.generate_hostnames(config)
41
- stack_hostnames = Array.new
42
- config[:roles].each do |role, role_details|
43
- fqdn = role.to_s + '.' + config[:dns_domain]
44
- stack_hostnames << fqdn
45
- end
46
- stack_hostnames
70
+ hostnames.each { |hostname| Logger.info " #{hostname}" }
47
71
  end
48
72
 
49
- def Stack.deploy_all(config)
50
- # create a connection
51
- connection = Stack.connect(config)
73
+ def Stack.select_stack(stackfile = 'Stackfile', stack_name)
74
+ # our local config file
75
+ config_raw = File.read(stackfile)
76
+ eval(config_raw)
52
77
 
53
- running_instances = Stack.get_running(config)
54
- config[:roles].each do |role, role_details|
55
- hostname = role.to_s
56
- fqdn = role.to_s + '.' + config[:dns_domain]
78
+ # if there is only one stack defined in the Stackfile, load it:
79
+ if StackConfig::Stacks.count == 1 && stack_name.nil?
80
+ stack_name = StackConfig::Stacks.keys[0]
81
+ Logger.debug { "defaulting to #{stack_name} as there is a single stack defined and no stack named" }
82
+ end
57
83
 
58
- if !running_instances[fqdn].nil?
59
- puts "Skipping #{fqdn} as it already exists"
60
- next
84
+ # returns a config object, injecting the name into the returned config
85
+ if StackConfig::Stacks[stack_name].nil?
86
+ Logger.error { "#{stack_name} is invalid, defined stacks are:" }
87
+ StackConfig::Stacks.each do |name, details|
88
+ Logger.error { " #{name}" }
61
89
  end
90
+ exit 2
91
+ end
92
+
93
+ config = StackConfig::Stacks[stack_name]
94
+ config[:name] = stack_name
95
+ # set the stackhome to the directory containing the Stackfile
96
+ config[:stackhome] = File.dirname(File.expand_path(stackfile))
97
+ Logger.info "stackhome is #{config[:stackhome]}"
98
+
99
+ config
100
+ end
101
+
102
+ def Stack.connect(config, region = nil)
103
+ # region & az concepts are confused in HPCS land
104
+ region = config['REGION'] if (region.nil? || region.length() < 1)
105
+
106
+ Logger.info "Connecting to OpenStack with region = #{region}"
107
+
108
+ OpenStack::Connection.create({
109
+ :auth_method=> 'password',
110
+ :username => config['USERNAME'],
111
+ :api_key=> config['PASSWORD'],
112
+ :auth_url => config['AUTH_URL'],
113
+ :authtenant_name => config['TENANT_NAME'],
114
+ :region => region,
115
+ :service_type=>"compute"
116
+ })
117
+ end
118
+
119
+ # expects server to be OpenStack::Compute::Server object
120
+ def Stack.get_addresses(server)
121
+
122
+ # get the addressess associated with an OpenStack::Compute::Server object
123
+ address_description = String.new
124
+ server.addresses.each do |address|
125
+ address_description << "#{address.address}(#{address.label}) "
126
+ end
127
+ address_description
128
+ end
129
+
130
+ # check that all the required config items are set
131
+ def Stack.syntax_check(config)
132
+ if config['REGION'].nil? || config['USERNAME'].nil? || config['PASSWORD'].nil? || config['AUTH_URL'].nil? || config['TENANT_NAME'].nil? &&
133
+ config['REGION'].empty? || config['USERNAME'].empty? || config['PASSWORD'].empty? || config['AUTH_URL'].empty? || config['TENANT_NAME'].empty?
134
+ Logger.error { "REGION, USERNAME, PASSWORD, AUTH_URL & TENANT_NAME must all be set" }
135
+ exit
136
+ end
62
137
 
63
- # Ubuntu 8.04/Hardy doesn't do full cloud-init, so we have to script setting the hostname
64
- user_data = File.read('user-data.sh')
65
- user_data.gsub!(/rentpro-unconfigured/, hostname)
66
- user_data.gsub!(/rentpro-stage.local/, config[:dns_domain])
67
-
68
- # pp multipart
69
- #
70
- puts "Bootstraping new instance - #{fqdn}"
71
- server = connection.servers.create({
72
- :name => fqdn,
73
- :hostname => fqdn,
74
- :availability_zone => config[:availability_zone],
75
- :flavor_id => config[:node_details][fqdn],
76
- :image_id => config[:image_id],
77
- :key_name => config[:keypair],
78
- :user_data => user_data,
79
- :tags => { 'Name' => fqdn },
80
- })
81
-
82
- print "Waiting for instance to be ready..."
83
- server.wait_for { ready? }
84
- puts "#{role.to_s} is booted, #{server.public_ip_address}/#{server.private_ip_address}"
85
-
86
- # create/update the public & private DNS for this host
87
- Stack.update_dns(role.to_s + '-public.' + config[:dns_domain], server.public_ip_address, config)
88
- Stack.update_dns(role.to_s + '-private.' + config[:dns_domain], server.private_ip_address, config)
138
+ # load defaults for any items not configured
139
+ Stack.populate_config(config)
140
+
141
+ if config[:provisioner] == 'chef'
142
+ # check that we have semi-sensible Chef setup
143
+ # at a bare minimum, we need the directory where we're going to download
144
+ # validation.pem to to exist
145
+ dot_chef_abs = File.absolute_path(config[:stackhome] + '/' + config[:dot_chef])
146
+ if !File.directory?(dot_chef_abs)
147
+ Logger.warn "#{dot_chef_abs} doesn't exist"
148
+ end
89
149
 
90
- # create the dns
91
- if (role_details[:publish_private_ip] == true && (!role_details[:publish_private_ip].nil?))
92
- ip_address = server.private_ip_address
150
+ # Check we have a #{dot_chef_abs}/.chef/knife.rb
151
+ knife_rb_abs = dot_chef_abs + '/knife.rb'
152
+ if File.exists?(knife_rb_abs)
153
+ Logger.info "Found #{knife_rb_abs}, lets hope it contains something sensible"
93
154
  else
94
- ip_address = server.public_ip_address
95
- end
96
- Stack.update_dns(fqdn, ip_address, config)
97
- #
98
- # is this a wildcard DNS host, then claim the *.domain.net
99
- if (role_details[:dns_wildcard] == true && (!role_details[:dns_wildcard].nil?))
100
- wildcard = "*." + config[:dns_domain]
101
- Stack.update_dns(wildcard, ip_address, config)
155
+ Logger.warn "#{knife_rb_abs} doesn't exist, please run './stack.rb configure-knife <stack-name>'"
102
156
  end
103
157
  end
104
158
  end
105
159
 
106
- def Stack.update_dns(fqdn, ip_address, config)
107
- # now register it in DNS
108
- dns = Fog::DNS.new({ :provider => config[:provider],
109
- :aws_access_key_id => config[:aws_access_key_id],
110
- :aws_secret_access_key => config[:aws_secret_access_key] })
111
-
112
- # pp dns.get_hosted_zone(config[:dns_id])
160
+ # validate that all our OpenStack creds, image_id, flavors, keys etc are valid
161
+ def Stack.validate(config)
162
+
163
+ Stack.syntax_check(config)
164
+
165
+ # check that the ssh-key is loaded, otherwise most post-install scripts will fail
166
+ # this lazily assumes that the :key_pair name matches the file the keys were loaded
167
+ # from
168
+ if (0 == 1)
169
+ ssh_keys_loaded = `ssh-add -L`
170
+ Logger.debug "ssh_keys_loaded: #{ssh_keys_loaded}"
171
+ Logger.debug "Looking for #{config[:key_pair]}"
172
+ if ssh_keys_loaded.include?(config[:key_pair])
173
+ Logger.info "Found #{config[:key_pair]} in the ssh-agent key list"
174
+ else
175
+ Logger.error "Couldn't find #{config[:key_pair]} key in the ssh-agent key list! Aborting!"
176
+ Logger.erroLogger.error "ssh_keys_loaded: #{ssh_keys_loaded}"
177
+ exit 2
178
+ end
179
+ end
180
+
181
+ # populate the config & then walk through the AZs verifying the config
182
+ Stack.populate_config(config)
183
+
184
+ # Check that we have valid details for each AZ
185
+ config[:azs].each do |az|
186
+
187
+ # check that credentials, flavor & image are valid
188
+ os = connect(config, az)
189
+
190
+ Logger.info "Checking that flavor #{config['flavor_id']} exists in #{az}..."
191
+ flavor = os.get_flavor(config['flavor_id'])
192
+ Logger.info "#{config['flavor_id']} is #{flavor.name}"
193
+
194
+ Logger.info "Checking that image #{config[az]['image_id']} exists in #{az}..."
195
+ image = os.get_image(config[az]['image_id'])
196
+ Logger.info "#{config[az]['image_id']} is #{image.name}"
197
+
198
+ Logger.info "Checking that keypair #{config[:key_pair]} exists in #{az}...."
199
+ keypairs = os.keypairs()
200
+ if (keypairs[config[:key_pair]].nil? && keypairs[config[:key_pair].to_sym].nil?)
201
+ Logger.warn "#{config[:key_pair]} isn't available, uploading the key"
113
202
 
114
- bmtw = dns.zones.get(config[:dns_id])
115
-
116
- record = bmtw.records.get(fqdn)
117
- if record
118
- record.modify(:value => ip_address) if record
119
- else
120
- bmtw.records.create(:value => ip_address, :name => fqdn, :type => 'A')
203
+ # upload the key
204
+ key = os.create_keypair({:name=> config[:key_pair], :public_key=> File.read(config[:key_public])})
205
+ Logger.warn "#{config[:key_pair]} fingerprint=#{key[:fingerprint]}"
206
+ else
207
+ Logger.info "#{config[:key_pair]} fingerprint=#{keypairs[config[:key_pair].to_sym][:fingerprint]}"
208
+ end
209
+
210
+ # TODO: check that security group exists
211
+ # we should have a security group that matches each role
212
+ # get all the secgroups
213
+ security_groups = os.security_groups()
214
+ # extract the names
215
+ sg_names = security_groups.map { |secgroup, secgroup_details| secgroup_details[:name] }
216
+
217
+ config[:roles].each do |role, role_details|
218
+ # is does the secgroup exist?
219
+ if sg_names.include?(role.to_s)
220
+ Logger.info "security group #{role} exists in #{az}"
221
+ else
222
+ Logger.error "security group #{role} is missing in #{az}"
223
+ end
224
+ end
121
225
  end
122
226
  end
123
227
 
124
- def Stack.show_dns(config)
125
- # now register it in DNS
126
- dns = Fog::DNS.new({ :provider => config[:provider],
127
- :aws_access_key_id => config[:aws_access_key_id],
128
- :aws_secret_access_key => config[:aws_secret_access_key] })
129
-
130
- zone = dns.zones.get(config[:dns_id])
131
- if zone.records.empty?
132
- puts "No DNS records found in #{config[:dns_domain]}"
133
- else
134
- printf("%40s %20s %5s %5s\n", 'fqdn', 'value', 'type', 'ttl')
135
- zone.records.each do |record|
136
- printf("%40s %20s %5s %5d\n", record.name, record.value, record.type, record.ttl)
228
+ def Stack.generate_knife_rb(config)
229
+ # generate a project/.chef/knife.rb from our config
230
+ # (assumes the chef server is running for public IP access etc)
231
+
232
+ # find the chef server, if we need to
233
+ if config[:chef_server_hostname].nil? || config[:chef_server_private].nil? || config[:chef_server_public]
234
+ Logger.debug { "Attempting to discover the chef server details" }
235
+ ours = Stack.get_our_instances(config)
236
+ ours.each do |node, node_details|
237
+ if node_details[:role] == :chef
238
+ Logger.debug { "Found the Chef server: #{node} #{node_details}" }
239
+ Stack.set_chef_server(config, node)
240
+ end
137
241
  end
138
- end
139
- end
242
+ end
243
+
244
+ # CWD shoud be chef-repo/bootstrap, so the project .chef directory should be
245
+ dot_chef_abs = File.absolute_path(config[:stackhome] + '/' + config[:dot_chef])
246
+
247
+ if !File.directory?(dot_chef_abs)
248
+ Logger.warn "#{dot_chef_abs} doesn't exist, creating it..."
249
+ Dir.mkdir(dot_chef_abs)
250
+ end
140
251
 
141
- def Stack.get_running(config)
142
- # create a connection
143
- connection = Stack.connect(config)
252
+ client_key = dot_chef_abs + '/' + config[:name] + '-' + ENV['USER'] + '.pem'
253
+ validation_key = dot_chef_abs + '/' + config[:name] + '-' + 'validation.pem'
144
254
 
145
- # generate all the names that this stack will use
146
- stack_hostnames = Stack.generate_hostnames(config)
147
-
148
- # Amazon EC2, use the tags hash to find hostnames
149
- running_instances = Hash.new
150
- connection.servers.each do |instance|
151
- # pp instance
152
- if (!instance.tags['Name'].nil? && instance.state != 'terminated' && instance.state != 'shutting-down')
153
- hostname = instance.tags['Name']
154
- if stack_hostnames.include?(hostname)
155
- running_instances[hostname] = instance
255
+ Logger.debug "stackhome: #{config[:stackhome]}"
256
+ Logger.debug "Current user client key: #{client_key}"
257
+ Logger.debug "New Host Validation key: #{validation_key}"
258
+
259
+ knife_rb_template = %q{
260
+ log_level :info
261
+ log_location STDOUT
262
+ node_name '<%=ENV['USER']%>'
263
+ # use the HPCS_ENV environment name to pick the correct key
264
+ client_key '<%=dot_chef_abs%>/' + ENV['HPCS_ENV'] + '-' + ENV['USER'] + '.pem'
265
+ validation_client_name 'chef-validator'
266
+ validation_key '<%=dot_chef_abs%>/' + ENV['HPCS_ENV'] + '-validation.pem'
267
+ chef_server_url '<%=config[:chef_server_public]%>'
268
+ cache_type 'BasicFile'
269
+ cache_options( :path => '<%=dot_chef_abs%>/checksums' )
270
+ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
271
+ }
272
+
273
+ knife_rb_erb = ERB.new(knife_rb_template)
274
+ knife_rb = knife_rb_erb.result(binding)
275
+
276
+ krb = File.new(dot_chef_abs + '/knife.rb', "w")
277
+ krb.truncate(0)
278
+ krb.puts knife_rb
279
+ krb.close
280
+ end
281
+
282
+ # position is really the node number in a role, i.e. 1..count
283
+ def Stack.generate_hostname(config, role, position)
284
+ role_details = config[:roles][role]
285
+
286
+ # TODO: don't calculate this everytime, shift out to a hash lookup
287
+ Logger.debug config
288
+ Logger.debug config[:site_template]
289
+ Logger.debug role_details
290
+ Logger.debug role_details[:azs]
291
+
292
+ site = sprintf(config[:site_template], role_details[:azs][position-1].split('.')[0].sub(/-/, ''))
293
+
294
+ # generate the hostname
295
+ hostname = sprintf(config[:name_template], config[:global_service_name], site, role, position)
296
+
297
+ hostname
298
+ end
299
+
300
+ def Stack.generate_server_names(config)
301
+ Stack.populate_config(config)
302
+ config[:hostnames] = config[:node_details].keys
303
+ config[:hostnames]
304
+ end
305
+
306
+ def Stack.populate_config(config)
307
+ # config[:role_details] contains built out role details with defaults filled in from stack defaults
308
+ # config[:node_details] contains node details built out from role_details
309
+
310
+ # set some sensible defaults to the stack-wide defaults if they haven't been set in the Stackfile.
311
+ if config[:provisioner].nil?
312
+ Logger.warn { "Defaulting to chef for config[:provisioner] "}
313
+ config[:provisioner] = 'chef'
314
+ end
315
+
316
+ if config[:dot_chef].nil?
317
+ Logger.warn { "Defaulting to .chef for config[:dot_chef] "}
318
+ config[:dot_chef] = '.chef'
319
+ end
320
+
321
+ if config[:chef_environment].nil?
322
+ Logger.warn { "Defaulting to _default for config[:chef_environment]" }
323
+ config[:chef_environment] = '_default'
324
+ end
325
+
326
+ if config[:chef_validation_pem].nil?
327
+ Logger.warn { "Defaulting to .chef/validation.pem for config[:chef_validation_pem]" }
328
+ config[:chef_validation_pem] = '.chef/validation.pem'
329
+ end
330
+
331
+ if config[:name_template].nil?
332
+ Logger.warn { "Defaulting to '%s-%s-%s%04d' for config[:name_template]" }
333
+ config[:name_template] = '%s-%s-%s%04d'
334
+ end
335
+
336
+ if config[:site_template].nil?
337
+ Logger.warn { "Defaulting to '%s' for config[:site_template]" }
338
+ config[:site_template] = '%s'
339
+ end
340
+
341
+ if config[:global_service_name].nil?
342
+ Logger.error { "Defaulting to 'UNKNOWN' for config[:global_service_name]" }
343
+ config[:site_template] = 'UNKNOWN'
344
+ end
345
+
346
+
347
+ if config[:node_details].nil?
348
+ Logger.debug { "Initializing config[:node_details] and config[:azs]" }
349
+ config[:node_details] = Hash.new
350
+ config[:azs] = Array.new
351
+
352
+ config[:roles].each do |role,role_details|
353
+ Logger.debug { "Setting defaults for #{role}" }
354
+
355
+ # default to 1 node of this role if :count isn't set
356
+ if role_details[:count].nil?
357
+ role_details[:count] = 1
156
358
  end
359
+
360
+ if (role_details[:data_dir].nil?)
361
+ role_details[:data_dir] = '/dummy'
362
+ end
363
+
364
+ # Has the cloud_config_yaml been overridden?
365
+ if (role_details[:cloud_config_yaml])
366
+ role_details[:cloud_config_yaml] = Stack.find_file(config, role_details[:cloud_config_yaml])
367
+ else
368
+ role_details[:cloud_config_yaml] = Stack.find_file(config, 'cloud-config.yaml')
369
+ end
370
+
371
+ # Has the default bootstrap script been overridden
372
+ if (role_details[:bootstrap])
373
+ if (role_details[:bootstrap].empty?)
374
+ Logger.debug { "role_details[:bootstrap] is empty, ignoring" }
375
+ else
376
+ role_details[:bootstrap] = Stack.find_file(config, role_details[:bootstrap])
377
+ end
378
+ else
379
+ role_details[:bootstrap] = Stack.find_file(config, 'chef-client-bootstrap-excl-validation-pem.sh')
380
+ end
381
+
382
+ # we default to the role name for the security group unless explicitly set
383
+ if role_details[:security_group].nil?
384
+ role_details[:security_group] = role.to_s
385
+ end
386
+
387
+ (1..role_details[:count]).each do |p|
388
+ Logger.debug { "Populating the config[:role_details][:azs] array with AZ" }
389
+ role_details[:azs] = Array.new if role_details[:azs].nil?
390
+
391
+ # is there an az set for this node?
392
+ if role_details[:azs][p-1].nil?
393
+ # inherit the global az
394
+ Logger.debug { "Inheriting the AZ for #{role} (#{config['REGION']})" }
395
+ role_details[:azs][p-1] = config['REGION']
396
+ end
397
+
398
+ # add this AZ to the AZ list, we'll dedupe later
399
+ config[:azs] << role_details[:azs][p-1]
400
+
401
+ hostname = Stack.generate_hostname(config, role, p)
402
+ Logger.debug { "Setting node_details for #{hostname}, using element #{p}-1 from #{role_details[:azs]}" }
403
+ config[:node_details][hostname] = { :az => role_details[:azs][p-1], :region => role_details[:azs][p-1], :role => role }
404
+ end
157
405
  end
158
406
  end
159
- running_instances
407
+ config[:azs].uniq!
408
+
409
+ # if set the region specific settings from the global settings if not already specified
410
+ config[:azs].each do |az|
411
+ # we store region spefic stuff in hash
412
+ config[az] = Hash.new if config[az].nil?
413
+
414
+ config[az]['image_id'] = config['image_id'] if config[az]['image_id'].nil?
415
+ end
416
+
417
+ config[:node_details]
418
+ end
419
+
420
+ # get all instances running in the current config
421
+ # return a hash where key is the instance name, value is another hash containing :region, :id, :addresses
422
+ def Stack.get_all_instances(config, refresh = false)
423
+ if config[:all_instances].nil? || refresh
424
+ # we need to get the server list for each AZ mentioned in the config[:roles][:role][:azs], this is populated by Stack.populate_config
425
+ Stack.populate_config(config)
426
+
427
+ # get the current list of servers from OpenStack & generate a hash, keyed on name
428
+ servers = Hash.new
429
+ config[:azs].each do |az|
430
+ os = Stack.connect(config, az)
431
+ os.servers.each do |server|
432
+ servers[server[:name]] = {
433
+ :region => az,
434
+ :id => server[:id],
435
+ :addresses => os.server(server[:id]).addresses
436
+ }
437
+ end
438
+ end
439
+ config[:all_instances] = servers
440
+ end
441
+ config[:all_instances]
160
442
  end
161
443
 
162
444
  def Stack.show_running(config)
163
- running_instances = Stack.get_running(config)
164
- running_instances.each do |instance, instance_details|
165
- # display some details
166
- puts "#{instance} id=#{instance_details.id} flavor_id=#{instance_details.flavor_id} public_ip=#{instance_details.public_ip_address} private_ip=#{instance_details.private_ip_address}"
445
+ # TODO: optionally show the hosts that are missing
446
+ ours = Stack.get_our_instances(config)
447
+
448
+ ours.each do |node, node_details|
449
+ printf("%-30s %20s %8d %16s %s\n", node, node_details[:region], node_details[:id], node_details[:role], node_details[:addresses].map { |address| address.address })
167
450
  end
168
451
  end
169
-
170
- def Stack.delete_node(config, fqdn)
171
- running_instances = Stack.get_running(config)
172
- if running_instances[fqdn].nil?
173
- puts "ERROR: #{fqdn} isn't running!"
174
- exit
452
+
453
+ # Add an instance to the :all_instances hash, instead of having to poll the whole lot again
454
+ def Stack.add_instance(config, hostname, region, id, addresses)
455
+ config[:all_instances][hostname] = { :region => region, :id => id, :addresses => addresses}
456
+ end
457
+
458
+ def Stack.ssh(config, hostname = nil, user = ENV['USER'], command = nil)
459
+ # ssh to a host, or all hosts
460
+
461
+ # get all running instances
462
+ servers = Stack.get_our_instances(config)
463
+
464
+ if hostname.nil?
465
+ Logger.debug { "request to SSH to all hosts" }
466
+ servers.each do |host, details|
467
+ public_ip = Stack.get_public_ip(config, host)
468
+ Logger.info { "#{host} #{public_ip}" }
469
+ cmd_output = `ssh -oStrictHostKeyChecking=no -l #{user} #{public_ip} "#{command}"`
470
+ Logger.info { "#{host} #{public_ip} #{cmd_output}" }
471
+ end
175
472
  else
176
- connection = Stack.connect(config)
177
- pp running_instances[fqdn]
178
- running_instances[fqdn].destroy
473
+ Logger.debug { "request to SSH to #{servers[hostname]}" }
179
474
  end
180
475
  end
181
476
 
182
- def Stack.show_details(config)
183
- # create a connection
184
- connection = Stack.connect(config)
185
-
186
- pp connection.describe_regions
187
- pp connection.describe_availability_zones
188
477
 
189
- pp connection.servers
478
+ def Stack.get_our_instances(config)
479
+ # build an hash of running instances that match our generated hostnames
480
+ node_details = Stack.populate_config(config)
190
481
 
191
- Stack.populate_config(config)
192
- pp config[:node_details]
482
+ # get all of our hostnames
483
+ hostnames = Stack.generate_server_names(config)
484
+
485
+ # get all running instances
486
+ servers = Stack.get_all_instances(config)
487
+
488
+ running = Hash.new
489
+ # do any of the list of servers in OpenStack match one of our hostnames?
490
+ hostnames.each do |hostname|
491
+ if (servers.include?(hostname))
492
+ # return the instance details merged with the node_details (info like role)
493
+ running[hostname] = servers[hostname].merge(node_details[hostname])
494
+ end
495
+ end
496
+
497
+ running
193
498
  end
194
499
 
195
- def upload_keys(config)
196
- if (key_pair = connection.key_pairs.get(config[:keypair]).nil?)
197
- key_pair = connection.key_pairs.create( :name => config[:keypair], :public_key => File.read("rentpro-deploy.pub") )
500
+ def Stack.delete_node(config, node)
501
+ # this also populates out unspecified defaults, like az
502
+ Stack.populate_config(config)
503
+ # get info about all instances running in our account & AZs
504
+ Stack.get_all_instances(config)
505
+
506
+ if (config[:all_instances][node].nil?)
507
+ Logger.info "Sorry, #{node} doesn't exist or isn't running"
198
508
  else
199
- puts "#{config[:keypair]} key_pair already exists"
509
+ Logger.info "Deleting node #{node} in #{config[:all_instances][node][:region]}..."
510
+ os = Stack.connect(config, config[:all_instances][node][:region])
511
+ instance = os.get_server(config[:all_instances][node][:id])
512
+ instance.delete!
513
+ end
514
+ end
515
+
516
+ def Stack.delete_all(config)
517
+ # this also populates out unspecified defaults, like az
518
+ Stack.populate_config(config)
519
+
520
+ # get the list of nodes we consider 'ours', i.e. with hostnames that match
521
+ # those generated by this stack
522
+ ours = Stack.get_our_instances(config)
523
+
524
+ # do any of the list of servers in OpenStack match one of our hostnames?
525
+ ours.each do |node, node_details|
526
+ Logger.info "Deleting #{node}"
527
+ os = Stack.connect(config, config[:all_instances][node][:region])
528
+ d = os.get_server(config[:all_instances][node][:id])
529
+ d.delete!
530
+ end
531
+ end
532
+
533
+ def Stack.get_public_ip(config, hostname)
534
+ # get a public address from the instance
535
+ # (could be either the dynamic or one of our floating IPs
536
+ config[:all_instances][hostname][:addresses].each do |address|
537
+ if address.label == 'public'
538
+ return address.address
539
+ end
540
+ end
541
+ end
542
+
543
+ def Stack.set_chef_server(config, chef_server)
544
+ # set the private & public URLs for the chef server,
545
+ # called either after we create the Chef Server, or skip over it
546
+ Logger.debug "Setting :chef_server_hostname, chef_server_private & chef_server_public details (using #{chef_server})"
547
+
548
+ config[:chef_server_hostname] = chef_server
549
+ # get the internal IP of this instance....which we should have stored in config[:all_instances]
550
+ if config[:all_instances][chef_server] && config[:all_instances][chef_server][:addresses]
551
+ config[:all_instances][chef_server][:addresses].each do |address|
552
+ # find the private IP, any old private IP will do...
553
+ if (address.label == 'private')
554
+ config[:chef_server_private] = "http://#{address.address}:4000/"
555
+ Logger.info "Setting the internal Chef URL to #{config[:chef_server_private]}"
556
+ end
557
+
558
+ # only set the public url if it hasn't been set in the config
559
+ if ((config[:chef_server_public].nil? || config[:chef_server_public].empty?) && address.label == 'public')
560
+ config[:chef_server_public] = "http://#{address.address}:4000/"
561
+ Logger.info "Setting the public Chef URL to #{config[:chef_server_public]}"
562
+ end
563
+ end
564
+ end
565
+ end
566
+
567
+ def Stack.secgroup_sync(config)
568
+ # 1) get all the IP information we have
569
+ # 2) generate the json to describe that to the "stackhelper secgroup-sync" tool
570
+ # 3) run "stackhelper secgroup-sync --some-file our-ips.json"
571
+ ours = Stack.get_our_instances(config)
572
+
573
+ secgroup_ips = Hash.new
574
+ # walk the list of hosts, dumping the IPs into role buckets
575
+ ours.each do |instance, instance_details|
576
+ secgroup_ips[instance_details[:role]] = Array.new if secgroup_ips[instance_details[:role]].nil?
577
+
578
+ #secgroup_ips[instance_details[:role]] << instance_details[:addresses].map { |address| address.address }
579
+ secgroup_ips[instance_details[:role]] << instance_details[:addresses].map do |address|
580
+ if (address.label == 'public')
581
+ address.address
582
+ else
583
+ next
584
+ end
585
+ end
586
+
587
+ # we potentially have an array of arrays, so flatten them
588
+ secgroup_ips[instance_details[:role]].flatten!
589
+
590
+ # delete any nil's that we collected due to skipping private ips
591
+ secgroup_ips[instance_details[:role]].delete_if {|x| x.nil? }
592
+ end
593
+
594
+ # dump the json to a temp file
595
+ #sg_json = Tempfile.new(['secgroup_ips', '.json'])
596
+ sg_json = File.new('secgroup_ips.json', "w")
597
+ sg_json.write(secgroup_ips.to_json)
598
+ sg_json.close
599
+
600
+ # run the secgroup-sync tool, across each AZ/REGION
601
+ config[:azs].each do |az|
602
+ Logger.info "Syncing security groups in #{az}"
603
+ system("stackhelper --os-region-name #{az} secgroup-sync --secgroup-json secgroups.json --additional-group-json #{sg_json.path}")
604
+ end
605
+ end
606
+
607
+ # if we're passed a role, only deploy this role.
608
+ def Stack.deploy_all(config, role_to_deploy = nil)
609
+ Stack.validate(config)
610
+
611
+ # this also populates out unspecified defaults, like az
612
+ node_details = Stack.populate_config(config)
613
+ # get info about all instances running in our account & AZs
614
+ servers = Stack.get_all_instances(config)
615
+
616
+ # this is our main loop iterator, generates each host
617
+ config[:roles].each do |role,role_details|
618
+ Logger.debug { "Iterating over roles, this is #{role}, role_details = #{role_details}" }
619
+
620
+ (1..role_details[:count]).each do |p|
621
+ hostname = Stack.generate_hostname(config, role, p)
622
+ Logger.debug { "Iterating over nodes in #{role}, this is #{hostname}" }
623
+
624
+ # configure the global :chef_server details if this the chef server
625
+ if role_details[:chef_server]
626
+ Stack.set_chef_server(config, hostname)
627
+ end
628
+
629
+ # does this node already exist?
630
+ if (!servers[hostname].nil?)
631
+ Logger.info { "#{hostname} already exists, skipping.." }
632
+ next
633
+ end
634
+
635
+ Logger.debug { "Deploying #{role}, role_to_deploy = #{role_to_deploy}" }
636
+ if ((role_to_deploy.nil?) || (role_to_deploy.to_s == role.to_s))
637
+ if (role_details[:skip_chef_prereg] == true || role_details[:chef_server])
638
+ Logger.debug "Skipping Chef pre-reg for #{hostname}"
639
+ else
640
+ # Prepare Chef
641
+ # 1) delete the client if it exists
642
+ knife_client_list = `knife client list | grep #{hostname}`
643
+ knife_client_list.sub!(/\s/,'')
644
+ if knife_client_list.length() > 0
645
+ # we should delete the client to make way for this new machine
646
+ Logger.info `knife client delete --yes #{hostname}`
647
+ end
648
+
649
+ # knife node create -d --environment $CHEF_ENVIRONMENT $SERVER_NAME
650
+ # knife node run_list add -d --environment $CHEF_ENVIRONMENT $SERVER_NAME "role[${ROLE}]"
651
+ # this relies on .chef matching the stacks config (TODO: poke the Chef API directly?)
652
+ cmd = "EDITOR=\"perl -p -i -e 's/_default/#{config[:chef_environment]}/'\" knife node create --server-url #{config[:chef_server_public]} #{hostname}"
653
+ Logger.debug cmd
654
+ knife_node_create = `#{cmd}`
655
+ Logger.info "Priming Chef Server: #{knife_node_create}"
656
+
657
+ cmd = "knife node run_list add -d --environment #{config[:chef_environment]} #{hostname} \"role[#{role}]\""
658
+ Logger.info cmd
659
+ knife_node_run_list = `#{cmd}`
660
+ Logger.info "Priming Chef Server: #{knife_node_run_list}"
661
+ end
662
+
663
+ # build the user-data content for this host
664
+ # (we have a local copy of https://github.com/lovelysystems/cloud-init/blob/master/tools/write-mime-multipart)
665
+ # 1) generate the mimi-multipart file
666
+ # libdir = where our shipped scripts live
667
+ # (use config[:stackhome] for "project" config/scripts)
668
+ libdir = File.realpath(@@gemhome + '/lib')
669
+ multipart_cmd = "#{libdir}/write-mime-multipart #{role_details[:bootstrap]} #{role_details[:cloud_config_yaml]}"
670
+ Logger.debug { "multipart_cmd = #{multipart_cmd}" }
671
+ multipart = `#{multipart_cmd}`
672
+ # 2) replace the tokens (CHEF_SERVER, CHEF_ENVIRONMENT, SERVER_NAME, ROLE)
673
+ multipart.gsub!(%q!%HOSTNAME%!, hostname)
674
+
675
+ Logger.info "Chef server is #{config[:chef_server_hostname]}, which is in #{config[:node_details][config[:chef_server_hostname]][:region]}"
676
+ Logger.info "#{hostname}'s region is #{config[:node_details][hostname][:region]}"
677
+ # if this host is in the same region/az, use the private URL, if not, use the public url
678
+ if (config[:node_details][hostname][:region] == config[:node_details][config[:chef_server_hostname]][:region]) && !config[:chef_server_private].nil?
679
+ multipart.gsub!(%q!%CHEF_SERVER%!, config[:chef_server_private])
680
+ elsif !config[:chef_server_public].nil?
681
+ multipart.gsub!(%q!%CHEF_SERVER%!, config[:chef_server_public])
682
+ else
683
+ Logger.warn { "Not setting the chef url for #{hostname} as neither chef_server_private or chef_server_public are valid yet" }
684
+ end
685
+ multipart.gsub!(%q!%CHEF_ENVIRONMENT%!, config[:chef_environment])
686
+ if File.exists?(config[:chef_validation_pem])
687
+ multipart.gsub!(%q!%CHEF_VALIDATION_PEM%!, File.read(config[:chef_validation_pem]))
688
+ else
689
+ Logger.warn "Skipping #{config[:chef_validation_pem]} substitution in user-data"
690
+ end
691
+ multipart.gsub!(%q!%SERVER_NAME%!, hostname)
692
+ multipart.gsub!(%q!%ROLE%!, role.to_s)
693
+ multipart.gsub!(%q!%DATA_DIR%!, role_details[:data_dir])
694
+
695
+ Logger.info "Creating #{hostname} in #{node_details[hostname][:az]} with role #{role}"
696
+
697
+ # this will get put in /meta.js
698
+ metadata = { 'region' => node_details[hostname][:az], 'chef_role' => role }
699
+
700
+ os = Stack.connect(config, node_details[hostname][:az])
701
+ newserver = os.create_server(:name => hostname,
702
+ :imageRef => config[node_details[hostname][:az]]['image_id'],
703
+ :flavorRef => config['flavor_id'],
704
+ :security_groups=>[role_details[:security_group]],
705
+ :user_data => Base64.encode64(multipart),
706
+ :metadata => metadata,
707
+ :key_name => config[:key_pair])
708
+
709
+ # wait for the server to become ACTIVE before proceeding
710
+ while (newserver.status != 'ACTIVE') do
711
+ print '.'
712
+ sleep 1
713
+ # refresh the status
714
+ newserver.refresh
715
+ end
716
+ puts
717
+
718
+ # refresh the config[:all_instances] with the newly built server
719
+ # TODO: we should be able to just add this server, instead of re-polling everything
720
+ Stack.get_all_instances(config, true)
721
+
722
+ # refresh the chef_server details..we should have IPs now
723
+ if role_details[:chef_server]
724
+ Stack.set_chef_server(config, hostname)
725
+ Stack.generate_knife_rb(config)
726
+ end
727
+
728
+ # attach a floating IP to this if we have one
729
+ if role_details[:floating_ips] && role_details[:floating_ips][p-1]
730
+ floating_ip = role_details[:floating_ips][p-1]
731
+ Logger.info "Attaching #{floating_ip} to #{hostname}\n"
732
+ # nova --os-region-name $REGION add-floating-ip $SERVER_NAME $FLOATING_IP
733
+ floating_ip_add = `nova --os-region-name #{node_details[hostname][:az]} add-floating-ip #{hostname} #{floating_ip}`
734
+ Logger.info floating_ip_add
735
+ end
736
+
737
+ # refresh the secgroups ASAP
738
+ Stack.secgroup_sync(config)
739
+
740
+ # run any post-install scripts, these are run from the current host, not the nodes
741
+ if role_details[:post_install_script]
742
+ # convert when we got passed to an absolute path
743
+ post_install_script_abs = File.realpath(config[:stackhome] + '/' + role_details[:post_install_script])
744
+ post_install_cwd_abs = File.realpath(config[:stackhome] + '/' + role_details[:post_install_cwd])
745
+
746
+ # replace any tokens in the argument
747
+ public_ip = Stack.get_public_ip(config, hostname)
748
+ role_details[:post_install_args].sub!(%q!%PUBLIC_IP%!, public_ip)
749
+ # we system this, as they are can give live feed back
750
+ Logger.info "Executing '#{post_install_script_abs} #{role_details[:post_install_args]}' as the post_install_script"
751
+ system("cd #{post_install_cwd_abs} ; #{post_install_script_abs} #{role_details[:post_install_args]}")
752
+ end
753
+ else
754
+ Logger.info "Skipped role #{role}"
755
+ end
756
+ end
200
757
  end
201
758
  end
202
759
 
203
- def shutdown_all(config)
204
- # shutdown all instances
205
- connection.servers.select do |server|
206
- puts "Running server:"
207
- # pp server
208
- # server.ready? && server.destroy
760
+ def Stack.find_file(config, filename)
761
+ # find a file, using the standard path precedence
762
+ # 1) cwd
763
+ # 2) stackhome
764
+ # 3) gemhome/lib
765
+ dirs = [ './' ]
766
+ dirs.push(config[:stackhome])
767
+ dirs.push(@@gemhome + '/lib')
768
+
769
+ Logger.debug "find_file, looking for #{filename} in #{dirs}"
770
+ filename_fqp = ''
771
+ dirs.each do |dir|
772
+ fqp = dir + '/' + filename
773
+ Logger.debug "find_file: checking #{fqp}"
774
+ if File.file?(fqp)
775
+ Logger.debug "find_file: found #{fqp}!"
776
+ filename_fqp = File.expand_path(fqp)
777
+ end
209
778
  end
779
+
780
+ if filename_fqp.empty?
781
+ Logger.warn "couldn't find #{filename} in #{dirs}"
782
+ end
783
+ filename_fqp
210
784
  end
211
785
 
212
786
  end
787
+