lib/stack.rb in stack-kicker-0.0.1 vs lib/stack.rb in stack-kicker-0.0.2

- old
+ new

@@ -1,212 +1,787 @@ -require 'pp' -require 'fog' +#!/usr/bin/env ruby +# Copyright 2012 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# Author: Simon McCartney <simon.mccartney@hp.com> +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +require 'bundler' +require 'rubygems' +require 'base64' +require 'erb' +require 'openstack' # https://github.com/ruby-openstack/ruby-openstack +require 'json' # http://www.ruby-doc.org/stdlib-1.9.3/libdoc/json/rdoc/JSON.html +require 'tempfile' + + +# +# This really needs to be converted into a class.... +# module Stack - def Stack.load_config(configfile, stack) - config_raw = File.read(configfile) - eval(config_raw) - config = StackConfig::Stacks[stack] - config + # Shadow the global constant Logger with Stack::Logger + # (if you want access to the global constant, use ::Logger from inside the Stack module) + Logger = Logger.new(STDOUT) + Logger.level = ::Logger::INFO + Logger.datetime_format = "%Y-%m-%d %H:%M:%S" + Logger.formatter = proc do |severity, datetime, progname, msg| + "#{datetime} #{severity}: #{msg}\n" end - def Stack.connect(config) - connection = Fog::Compute.new({ :provider => config[:provider], - :aws_access_key_id => config[:aws_access_key_id], - :aws_secret_access_key => config[:aws_secret_access_key], - :region => config[:region] }) - connection + # location of gem, where config[:gemhome]/lib contains our default cloud-init templates + @@gemhome = File.absolute_path(File.realpath(File.dirname(File.expand_path(__FILE__)) + '/..')) + + # Methadone::CLILogger is a Class, Stack is still a module, so we can't include it + # so this is a QADH to propagate the log_level + def Stack.log_level(level) + Logger.debug { "Setting the Logger.level to #{level}" } + Logger.level = level end - def Stack.populate_config(config) - # build out the full config for each node, supplying defaults from the - # global config if explicitly supplied - config[:node_details] = Hash.new if config[:node_details].nil? + def Stack.show_stacks(stackfile = 'Stackfile') + # our local config file + config_raw = File.read(stackfile) + eval(config_raw) - config[:roles].each do |role, role_details| - fqdn = role.to_s + '.' + config[:dns_domain] - - config[:node_details][fqdn] = { - # set the node details from the role, if not specified in the role, use the config global - # (takes advantage of left to right evaluation of ||) - :flavor_id => (role_details[:flavor_id] || config[:flavor_id]), - :count => (role_details[:count] || 1), - :publish_private_ip => (role_details[:publish_private_ip] || false), - :dns_wildcard => (role_details[:dns_wildcard] || false) - } + Logger.info { "Stacks:" } + StackConfig::Stacks.each do |name, details| + Logger.info { " #{name}" } end end + + def Stack.show_stack(config) + # syntax_check is a light weight check that doesn't talk to OpenStalk + Stack.syntax_check(config) + # generate an array of hostnames that this stack would create + hostnames = Stack.generate_server_names(config) - def Stack.generate_hostnames(config) - stack_hostnames = Array.new - config[:roles].each do |role, role_details| - fqdn = role.to_s + '.' + config[:dns_domain] - stack_hostnames << fqdn - end - stack_hostnames + hostnames.each { |hostname| Logger.info " #{hostname}" } end - def Stack.deploy_all(config) - # create a connection - connection = Stack.connect(config) + def Stack.select_stack(stackfile = 'Stackfile', stack_name) + # our local config file + config_raw = File.read(stackfile) + eval(config_raw) - running_instances = Stack.get_running(config) - config[:roles].each do |role, role_details| - hostname = role.to_s - fqdn = role.to_s + '.' + config[:dns_domain] + # if there is only one stack defined in the Stackfile, load it: + if StackConfig::Stacks.count == 1 && stack_name.nil? + stack_name = StackConfig::Stacks.keys[0] + Logger.debug { "defaulting to #{stack_name} as there is a single stack defined and no stack named" } + end - if !running_instances[fqdn].nil? - puts "Skipping #{fqdn} as it already exists" - next + # returns a config object, injecting the name into the returned config + if StackConfig::Stacks[stack_name].nil? + Logger.error { "#{stack_name} is invalid, defined stacks are:" } + StackConfig::Stacks.each do |name, details| + Logger.error { " #{name}" } end + exit 2 + end - # Ubuntu 8.04/Hardy doesn't do full cloud-init, so we have to script setting the hostname - user_data = File.read('user-data.sh') - user_data.gsub!(/rentpro-unconfigured/, hostname) - user_data.gsub!(/rentpro-stage.local/, config[:dns_domain]) + config = StackConfig::Stacks[stack_name] + config[:name] = stack_name + # set the stackhome to the directory containing the Stackfile + config[:stackhome] = File.dirname(File.expand_path(stackfile)) + Logger.info "stackhome is #{config[:stackhome]}" - # pp multipart - # - puts "Bootstraping new instance - #{fqdn}" - server = connection.servers.create({ - :name => fqdn, - :hostname => fqdn, - :availability_zone => config[:availability_zone], - :flavor_id => config[:node_details][fqdn], - :image_id => config[:image_id], - :key_name => config[:keypair], - :user_data => user_data, - :tags => { 'Name' => fqdn }, - }) + config + end - print "Waiting for instance to be ready..." - server.wait_for { ready? } - puts "#{role.to_s} is booted, #{server.public_ip_address}/#{server.private_ip_address}" + def Stack.connect(config, region = nil) + # region & az concepts are confused in HPCS land + region = config['REGION'] if (region.nil? || region.length() < 1) - # create/update the public & private DNS for this host - Stack.update_dns(role.to_s + '-public.' + config[:dns_domain], server.public_ip_address, config) - Stack.update_dns(role.to_s + '-private.' + config[:dns_domain], server.private_ip_address, config) + Logger.info "Connecting to OpenStack with region = #{region}" + + OpenStack::Connection.create({ + :auth_method=> 'password', + :username => config['USERNAME'], + :api_key=> config['PASSWORD'], + :auth_url => config['AUTH_URL'], + :authtenant_name => config['TENANT_NAME'], + :region => region, + :service_type=>"compute" + }) + end + + # expects server to be OpenStack::Compute::Server object + def Stack.get_addresses(server) + + # get the addressess associated with an OpenStack::Compute::Server object + address_description = String.new + server.addresses.each do |address| + address_description << "#{address.address}(#{address.label}) " + end + address_description + end + + # check that all the required config items are set + def Stack.syntax_check(config) + if config['REGION'].nil? || config['USERNAME'].nil? || config['PASSWORD'].nil? || config['AUTH_URL'].nil? || config['TENANT_NAME'].nil? && + config['REGION'].empty? || config['USERNAME'].empty? || config['PASSWORD'].empty? || config['AUTH_URL'].empty? || config['TENANT_NAME'].empty? + Logger.error { "REGION, USERNAME, PASSWORD, AUTH_URL & TENANT_NAME must all be set" } + exit + end + + # load defaults for any items not configured + Stack.populate_config(config) + + if config[:provisioner] == 'chef' + # check that we have semi-sensible Chef setup + # at a bare minimum, we need the directory where we're going to download + # validation.pem to to exist + dot_chef_abs = File.absolute_path(config[:stackhome] + '/' + config[:dot_chef]) + if !File.directory?(dot_chef_abs) + Logger.warn "#{dot_chef_abs} doesn't exist" + end - # create the dns - if (role_details[:publish_private_ip] == true && (!role_details[:publish_private_ip].nil?)) - ip_address = server.private_ip_address + # Check we have a #{dot_chef_abs}/.chef/knife.rb + knife_rb_abs = dot_chef_abs + '/knife.rb' + if File.exists?(knife_rb_abs) + Logger.info "Found #{knife_rb_abs}, lets hope it contains something sensible" else - ip_address = server.public_ip_address + Logger.warn "#{knife_rb_abs} doesn't exist, please run './stack.rb configure-knife <stack-name>'" end - Stack.update_dns(fqdn, ip_address, config) - # - # is this a wildcard DNS host, then claim the *.domain.net - if (role_details[:dns_wildcard] == true && (!role_details[:dns_wildcard].nil?)) - wildcard = "*." + config[:dns_domain] - Stack.update_dns(wildcard, ip_address, config) - end end end - def Stack.update_dns(fqdn, ip_address, config) - # now register it in DNS - dns = Fog::DNS.new({ :provider => config[:provider], - :aws_access_key_id => config[:aws_access_key_id], - :aws_secret_access_key => config[:aws_secret_access_key] }) - - # pp dns.get_hosted_zone(config[:dns_id]) + # validate that all our OpenStack creds, image_id, flavors, keys etc are valid + def Stack.validate(config) + + Stack.syntax_check(config) + + # check that the ssh-key is loaded, otherwise most post-install scripts will fail + # this lazily assumes that the :key_pair name matches the file the keys were loaded + # from + if (0 == 1) + ssh_keys_loaded = `ssh-add -L` + Logger.debug "ssh_keys_loaded: #{ssh_keys_loaded}" + Logger.debug "Looking for #{config[:key_pair]}" + if ssh_keys_loaded.include?(config[:key_pair]) + Logger.info "Found #{config[:key_pair]} in the ssh-agent key list" + else + Logger.error "Couldn't find #{config[:key_pair]} key in the ssh-agent key list! Aborting!" + Logger.erroLogger.error "ssh_keys_loaded: #{ssh_keys_loaded}" + exit 2 + end + end + + # populate the config & then walk through the AZs verifying the config + Stack.populate_config(config) + + # Check that we have valid details for each AZ + config[:azs].each do |az| + + # check that credentials, flavor & image are valid + os = connect(config, az) + + Logger.info "Checking that flavor #{config['flavor_id']} exists in #{az}..." + flavor = os.get_flavor(config['flavor_id']) + Logger.info "#{config['flavor_id']} is #{flavor.name}" + + Logger.info "Checking that image #{config[az]['image_id']} exists in #{az}..." + image = os.get_image(config[az]['image_id']) + Logger.info "#{config[az]['image_id']} is #{image.name}" + + Logger.info "Checking that keypair #{config[:key_pair]} exists in #{az}...." + keypairs = os.keypairs() + if (keypairs[config[:key_pair]].nil? && keypairs[config[:key_pair].to_sym].nil?) + Logger.warn "#{config[:key_pair]} isn't available, uploading the key" - bmtw = dns.zones.get(config[:dns_id]) - - record = bmtw.records.get(fqdn) - if record - record.modify(:value => ip_address) if record - else - bmtw.records.create(:value => ip_address, :name => fqdn, :type => 'A') + # upload the key + key = os.create_keypair({:name=> config[:key_pair], :public_key=> File.read(config[:key_public])}) + Logger.warn "#{config[:key_pair]} fingerprint=#{key[:fingerprint]}" + else + Logger.info "#{config[:key_pair]} fingerprint=#{keypairs[config[:key_pair].to_sym][:fingerprint]}" + end + + # TODO: check that security group exists + # we should have a security group that matches each role + # get all the secgroups + security_groups = os.security_groups() + # extract the names + sg_names = security_groups.map { |secgroup, secgroup_details| secgroup_details[:name] } + + config[:roles].each do |role, role_details| + # is does the secgroup exist? + if sg_names.include?(role.to_s) + Logger.info "security group #{role} exists in #{az}" + else + Logger.error "security group #{role} is missing in #{az}" + end + end end end - def Stack.show_dns(config) - # now register it in DNS - dns = Fog::DNS.new({ :provider => config[:provider], - :aws_access_key_id => config[:aws_access_key_id], - :aws_secret_access_key => config[:aws_secret_access_key] }) - - zone = dns.zones.get(config[:dns_id]) - if zone.records.empty? - puts "No DNS records found in #{config[:dns_domain]}" - else - printf("%40s %20s %5s %5s\n", 'fqdn', 'value', 'type', 'ttl') - zone.records.each do |record| - printf("%40s %20s %5s %5d\n", record.name, record.value, record.type, record.ttl) + def Stack.generate_knife_rb(config) + # generate a project/.chef/knife.rb from our config + # (assumes the chef server is running for public IP access etc) + + # find the chef server, if we need to + if config[:chef_server_hostname].nil? || config[:chef_server_private].nil? || config[:chef_server_public] + Logger.debug { "Attempting to discover the chef server details" } + ours = Stack.get_our_instances(config) + ours.each do |node, node_details| + if node_details[:role] == :chef + Logger.debug { "Found the Chef server: #{node} #{node_details}" } + Stack.set_chef_server(config, node) + end end - end - end + end - def Stack.get_running(config) - # create a connection - connection = Stack.connect(config) + # CWD shoud be chef-repo/bootstrap, so the project .chef directory should be + dot_chef_abs = File.absolute_path(config[:stackhome] + '/' + config[:dot_chef]) + + if !File.directory?(dot_chef_abs) + Logger.warn "#{dot_chef_abs} doesn't exist, creating it..." + Dir.mkdir(dot_chef_abs) + end + + client_key = dot_chef_abs + '/' + config[:name] + '-' + ENV['USER'] + '.pem' + validation_key = dot_chef_abs + '/' + config[:name] + '-' + 'validation.pem' - # generate all the names that this stack will use - stack_hostnames = Stack.generate_hostnames(config) + Logger.debug "stackhome: #{config[:stackhome]}" + Logger.debug "Current user client key: #{client_key}" + Logger.debug "New Host Validation key: #{validation_key}" - # Amazon EC2, use the tags hash to find hostnames - running_instances = Hash.new - connection.servers.each do |instance| - # pp instance - if (!instance.tags['Name'].nil? && instance.state != 'terminated' && instance.state != 'shutting-down') - hostname = instance.tags['Name'] - if stack_hostnames.include?(hostname) - running_instances[hostname] = instance + knife_rb_template = %q{ +log_level :info +log_location STDOUT +node_name '<%=ENV['USER']%>' +# use the HPCS_ENV environment name to pick the correct key +client_key '<%=dot_chef_abs%>/' + ENV['HPCS_ENV'] + '-' + ENV['USER'] + '.pem' +validation_client_name 'chef-validator' +validation_key '<%=dot_chef_abs%>/' + ENV['HPCS_ENV'] + '-validation.pem' +chef_server_url '<%=config[:chef_server_public]%>' +cache_type 'BasicFile' +cache_options( :path => '<%=dot_chef_abs%>/checksums' ) +cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ] + } + + knife_rb_erb = ERB.new(knife_rb_template) + knife_rb = knife_rb_erb.result(binding) + + krb = File.new(dot_chef_abs + '/knife.rb', "w") + krb.truncate(0) + krb.puts knife_rb + krb.close + end + + # position is really the node number in a role, i.e. 1..count + def Stack.generate_hostname(config, role, position) + role_details = config[:roles][role] + + # TODO: don't calculate this everytime, shift out to a hash lookup + Logger.debug config + Logger.debug config[:site_template] + Logger.debug role_details + Logger.debug role_details[:azs] + + site = sprintf(config[:site_template], role_details[:azs][position-1].split('.')[0].sub(/-/, '')) + + # generate the hostname + hostname = sprintf(config[:name_template], config[:global_service_name], site, role, position) + + hostname + end + + def Stack.generate_server_names(config) + Stack.populate_config(config) + config[:hostnames] = config[:node_details].keys + config[:hostnames] + end + + def Stack.populate_config(config) + # config[:role_details] contains built out role details with defaults filled in from stack defaults + # config[:node_details] contains node details built out from role_details + + # set some sensible defaults to the stack-wide defaults if they haven't been set in the Stackfile. + if config[:provisioner].nil? + Logger.warn { "Defaulting to chef for config[:provisioner] "} + config[:provisioner] = 'chef' + end + + if config[:dot_chef].nil? + Logger.warn { "Defaulting to .chef for config[:dot_chef] "} + config[:dot_chef] = '.chef' + end + + if config[:chef_environment].nil? + Logger.warn { "Defaulting to _default for config[:chef_environment]" } + config[:chef_environment] = '_default' + end + + if config[:chef_validation_pem].nil? + Logger.warn { "Defaulting to .chef/validation.pem for config[:chef_validation_pem]" } + config[:chef_validation_pem] = '.chef/validation.pem' + end + + if config[:name_template].nil? + Logger.warn { "Defaulting to '%s-%s-%s%04d' for config[:name_template]" } + config[:name_template] = '%s-%s-%s%04d' + end + + if config[:site_template].nil? + Logger.warn { "Defaulting to '%s' for config[:site_template]" } + config[:site_template] = '%s' + end + + if config[:global_service_name].nil? + Logger.error { "Defaulting to 'UNKNOWN' for config[:global_service_name]" } + config[:site_template] = 'UNKNOWN' + end + + + if config[:node_details].nil? + Logger.debug { "Initializing config[:node_details] and config[:azs]" } + config[:node_details] = Hash.new + config[:azs] = Array.new + + config[:roles].each do |role,role_details| + Logger.debug { "Setting defaults for #{role}" } + + # default to 1 node of this role if :count isn't set + if role_details[:count].nil? + role_details[:count] = 1 end + + if (role_details[:data_dir].nil?) + role_details[:data_dir] = '/dummy' + end + + # Has the cloud_config_yaml been overridden? + if (role_details[:cloud_config_yaml]) + role_details[:cloud_config_yaml] = Stack.find_file(config, role_details[:cloud_config_yaml]) + else + role_details[:cloud_config_yaml] = Stack.find_file(config, 'cloud-config.yaml') + end + + # Has the default bootstrap script been overridden + if (role_details[:bootstrap]) + if (role_details[:bootstrap].empty?) + Logger.debug { "role_details[:bootstrap] is empty, ignoring" } + else + role_details[:bootstrap] = Stack.find_file(config, role_details[:bootstrap]) + end + else + role_details[:bootstrap] = Stack.find_file(config, 'chef-client-bootstrap-excl-validation-pem.sh') + end + + # we default to the role name for the security group unless explicitly set + if role_details[:security_group].nil? + role_details[:security_group] = role.to_s + end + + (1..role_details[:count]).each do |p| + Logger.debug { "Populating the config[:role_details][:azs] array with AZ" } + role_details[:azs] = Array.new if role_details[:azs].nil? + + # is there an az set for this node? + if role_details[:azs][p-1].nil? + # inherit the global az + Logger.debug { "Inheriting the AZ for #{role} (#{config['REGION']})" } + role_details[:azs][p-1] = config['REGION'] + end + + # add this AZ to the AZ list, we'll dedupe later + config[:azs] << role_details[:azs][p-1] + + hostname = Stack.generate_hostname(config, role, p) + Logger.debug { "Setting node_details for #{hostname}, using element #{p}-1 from #{role_details[:azs]}" } + config[:node_details][hostname] = { :az => role_details[:azs][p-1], :region => role_details[:azs][p-1], :role => role } + end end end - running_instances + config[:azs].uniq! + + # if set the region specific settings from the global settings if not already specified + config[:azs].each do |az| + # we store region spefic stuff in hash + config[az] = Hash.new if config[az].nil? + + config[az]['image_id'] = config['image_id'] if config[az]['image_id'].nil? + end + + config[:node_details] end + # get all instances running in the current config + # return a hash where key is the instance name, value is another hash containing :region, :id, :addresses + def Stack.get_all_instances(config, refresh = false) + if config[:all_instances].nil? || refresh + # we need to get the server list for each AZ mentioned in the config[:roles][:role][:azs], this is populated by Stack.populate_config + Stack.populate_config(config) + + # get the current list of servers from OpenStack & generate a hash, keyed on name + servers = Hash.new + config[:azs].each do |az| + os = Stack.connect(config, az) + os.servers.each do |server| + servers[server[:name]] = { + :region => az, + :id => server[:id], + :addresses => os.server(server[:id]).addresses + } + end + end + config[:all_instances] = servers + end + config[:all_instances] + end + def Stack.show_running(config) - running_instances = Stack.get_running(config) - running_instances.each do |instance, instance_details| - # display some details - 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}" + # TODO: optionally show the hosts that are missing + ours = Stack.get_our_instances(config) + + ours.each do |node, node_details| + printf("%-30s %20s %8d %16s %s\n", node, node_details[:region], node_details[:id], node_details[:role], node_details[:addresses].map { |address| address.address }) end end - - def Stack.delete_node(config, fqdn) - running_instances = Stack.get_running(config) - if running_instances[fqdn].nil? - puts "ERROR: #{fqdn} isn't running!" - exit + + # Add an instance to the :all_instances hash, instead of having to poll the whole lot again + def Stack.add_instance(config, hostname, region, id, addresses) + config[:all_instances][hostname] = { :region => region, :id => id, :addresses => addresses} + end + + def Stack.ssh(config, hostname = nil, user = ENV['USER'], command = nil) + # ssh to a host, or all hosts + + # get all running instances + servers = Stack.get_our_instances(config) + + if hostname.nil? + Logger.debug { "request to SSH to all hosts" } + servers.each do |host, details| + public_ip = Stack.get_public_ip(config, host) + Logger.info { "#{host} #{public_ip}" } + cmd_output = `ssh -oStrictHostKeyChecking=no -l #{user} #{public_ip} "#{command}"` + Logger.info { "#{host} #{public_ip} #{cmd_output}" } + end else - connection = Stack.connect(config) - pp running_instances[fqdn] - running_instances[fqdn].destroy + Logger.debug { "request to SSH to #{servers[hostname]}" } end end - def Stack.show_details(config) - # create a connection - connection = Stack.connect(config) - - pp connection.describe_regions - pp connection.describe_availability_zones - pp connection.servers + def Stack.get_our_instances(config) + # build an hash of running instances that match our generated hostnames + node_details = Stack.populate_config(config) - Stack.populate_config(config) - pp config[:node_details] + # get all of our hostnames + hostnames = Stack.generate_server_names(config) + + # get all running instances + servers = Stack.get_all_instances(config) + + running = Hash.new + # do any of the list of servers in OpenStack match one of our hostnames? + hostnames.each do |hostname| + if (servers.include?(hostname)) + # return the instance details merged with the node_details (info like role) + running[hostname] = servers[hostname].merge(node_details[hostname]) + end + end + + running end - def upload_keys(config) - if (key_pair = connection.key_pairs.get(config[:keypair]).nil?) - key_pair = connection.key_pairs.create( :name => config[:keypair], :public_key => File.read("rentpro-deploy.pub") ) + def Stack.delete_node(config, node) + # this also populates out unspecified defaults, like az + Stack.populate_config(config) + # get info about all instances running in our account & AZs + Stack.get_all_instances(config) + + if (config[:all_instances][node].nil?) + Logger.info "Sorry, #{node} doesn't exist or isn't running" else - puts "#{config[:keypair]} key_pair already exists" + Logger.info "Deleting node #{node} in #{config[:all_instances][node][:region]}..." + os = Stack.connect(config, config[:all_instances][node][:region]) + instance = os.get_server(config[:all_instances][node][:id]) + instance.delete! end end - def shutdown_all(config) - # shutdown all instances - connection.servers.select do |server| - puts "Running server:" - # pp server - # server.ready? && server.destroy + def Stack.delete_all(config) + # this also populates out unspecified defaults, like az + Stack.populate_config(config) + + # get the list of nodes we consider 'ours', i.e. with hostnames that match + # those generated by this stack + ours = Stack.get_our_instances(config) + + # do any of the list of servers in OpenStack match one of our hostnames? + ours.each do |node, node_details| + Logger.info "Deleting #{node}" + os = Stack.connect(config, config[:all_instances][node][:region]) + d = os.get_server(config[:all_instances][node][:id]) + d.delete! end end + + def Stack.get_public_ip(config, hostname) + # get a public address from the instance + # (could be either the dynamic or one of our floating IPs + config[:all_instances][hostname][:addresses].each do |address| + if address.label == 'public' + return address.address + end + end + end + def Stack.set_chef_server(config, chef_server) + # set the private & public URLs for the chef server, + # called either after we create the Chef Server, or skip over it + Logger.debug "Setting :chef_server_hostname, chef_server_private & chef_server_public details (using #{chef_server})" + + config[:chef_server_hostname] = chef_server + # get the internal IP of this instance....which we should have stored in config[:all_instances] + if config[:all_instances][chef_server] && config[:all_instances][chef_server][:addresses] + config[:all_instances][chef_server][:addresses].each do |address| + # find the private IP, any old private IP will do... + if (address.label == 'private') + config[:chef_server_private] = "http://#{address.address}:4000/" + Logger.info "Setting the internal Chef URL to #{config[:chef_server_private]}" + end + + # only set the public url if it hasn't been set in the config + if ((config[:chef_server_public].nil? || config[:chef_server_public].empty?) && address.label == 'public') + config[:chef_server_public] = "http://#{address.address}:4000/" + Logger.info "Setting the public Chef URL to #{config[:chef_server_public]}" + end + end + end + end + + def Stack.secgroup_sync(config) + # 1) get all the IP information we have + # 2) generate the json to describe that to the "stackhelper secgroup-sync" tool + # 3) run "stackhelper secgroup-sync --some-file our-ips.json" + ours = Stack.get_our_instances(config) + + secgroup_ips = Hash.new + # walk the list of hosts, dumping the IPs into role buckets + ours.each do |instance, instance_details| + secgroup_ips[instance_details[:role]] = Array.new if secgroup_ips[instance_details[:role]].nil? + + #secgroup_ips[instance_details[:role]] << instance_details[:addresses].map { |address| address.address } + secgroup_ips[instance_details[:role]] << instance_details[:addresses].map do |address| + if (address.label == 'public') + address.address + else + next + end + end + + # we potentially have an array of arrays, so flatten them + secgroup_ips[instance_details[:role]].flatten! + + # delete any nil's that we collected due to skipping private ips + secgroup_ips[instance_details[:role]].delete_if {|x| x.nil? } + end + + # dump the json to a temp file + #sg_json = Tempfile.new(['secgroup_ips', '.json']) + sg_json = File.new('secgroup_ips.json', "w") + sg_json.write(secgroup_ips.to_json) + sg_json.close + + # run the secgroup-sync tool, across each AZ/REGION + config[:azs].each do |az| + Logger.info "Syncing security groups in #{az}" + system("stackhelper --os-region-name #{az} secgroup-sync --secgroup-json secgroups.json --additional-group-json #{sg_json.path}") + end + end + + # if we're passed a role, only deploy this role. + def Stack.deploy_all(config, role_to_deploy = nil) + Stack.validate(config) + + # this also populates out unspecified defaults, like az + node_details = Stack.populate_config(config) + # get info about all instances running in our account & AZs + servers = Stack.get_all_instances(config) + + # this is our main loop iterator, generates each host + config[:roles].each do |role,role_details| + Logger.debug { "Iterating over roles, this is #{role}, role_details = #{role_details}" } + + (1..role_details[:count]).each do |p| + hostname = Stack.generate_hostname(config, role, p) + Logger.debug { "Iterating over nodes in #{role}, this is #{hostname}" } + + # configure the global :chef_server details if this the chef server + if role_details[:chef_server] + Stack.set_chef_server(config, hostname) + end + + # does this node already exist? + if (!servers[hostname].nil?) + Logger.info { "#{hostname} already exists, skipping.." } + next + end + + Logger.debug { "Deploying #{role}, role_to_deploy = #{role_to_deploy}" } + if ((role_to_deploy.nil?) || (role_to_deploy.to_s == role.to_s)) + if (role_details[:skip_chef_prereg] == true || role_details[:chef_server]) + Logger.debug "Skipping Chef pre-reg for #{hostname}" + else + # Prepare Chef + # 1) delete the client if it exists + knife_client_list = `knife client list | grep #{hostname}` + knife_client_list.sub!(/\s/,'') + if knife_client_list.length() > 0 + # we should delete the client to make way for this new machine + Logger.info `knife client delete --yes #{hostname}` + end + + # knife node create -d --environment $CHEF_ENVIRONMENT $SERVER_NAME + # knife node run_list add -d --environment $CHEF_ENVIRONMENT $SERVER_NAME "role[${ROLE}]" + # this relies on .chef matching the stacks config (TODO: poke the Chef API directly?) + cmd = "EDITOR=\"perl -p -i -e 's/_default/#{config[:chef_environment]}/'\" knife node create --server-url #{config[:chef_server_public]} #{hostname}" + Logger.debug cmd + knife_node_create = `#{cmd}` + Logger.info "Priming Chef Server: #{knife_node_create}" + + cmd = "knife node run_list add -d --environment #{config[:chef_environment]} #{hostname} \"role[#{role}]\"" + Logger.info cmd + knife_node_run_list = `#{cmd}` + Logger.info "Priming Chef Server: #{knife_node_run_list}" + end + + # build the user-data content for this host + # (we have a local copy of https://github.com/lovelysystems/cloud-init/blob/master/tools/write-mime-multipart) + # 1) generate the mimi-multipart file + # libdir = where our shipped scripts live + # (use config[:stackhome] for "project" config/scripts) + libdir = File.realpath(@@gemhome + '/lib') + multipart_cmd = "#{libdir}/write-mime-multipart #{role_details[:bootstrap]} #{role_details[:cloud_config_yaml]}" + Logger.debug { "multipart_cmd = #{multipart_cmd}" } + multipart = `#{multipart_cmd}` + # 2) replace the tokens (CHEF_SERVER, CHEF_ENVIRONMENT, SERVER_NAME, ROLE) + multipart.gsub!(%q!%HOSTNAME%!, hostname) + + Logger.info "Chef server is #{config[:chef_server_hostname]}, which is in #{config[:node_details][config[:chef_server_hostname]][:region]}" + Logger.info "#{hostname}'s region is #{config[:node_details][hostname][:region]}" + # if this host is in the same region/az, use the private URL, if not, use the public url + if (config[:node_details][hostname][:region] == config[:node_details][config[:chef_server_hostname]][:region]) && !config[:chef_server_private].nil? + multipart.gsub!(%q!%CHEF_SERVER%!, config[:chef_server_private]) + elsif !config[:chef_server_public].nil? + multipart.gsub!(%q!%CHEF_SERVER%!, config[:chef_server_public]) + else + Logger.warn { "Not setting the chef url for #{hostname} as neither chef_server_private or chef_server_public are valid yet" } + end + multipart.gsub!(%q!%CHEF_ENVIRONMENT%!, config[:chef_environment]) + if File.exists?(config[:chef_validation_pem]) + multipart.gsub!(%q!%CHEF_VALIDATION_PEM%!, File.read(config[:chef_validation_pem])) + else + Logger.warn "Skipping #{config[:chef_validation_pem]} substitution in user-data" + end + multipart.gsub!(%q!%SERVER_NAME%!, hostname) + multipart.gsub!(%q!%ROLE%!, role.to_s) + multipart.gsub!(%q!%DATA_DIR%!, role_details[:data_dir]) + + Logger.info "Creating #{hostname} in #{node_details[hostname][:az]} with role #{role}" + + # this will get put in /meta.js + metadata = { 'region' => node_details[hostname][:az], 'chef_role' => role } + + os = Stack.connect(config, node_details[hostname][:az]) + newserver = os.create_server(:name => hostname, + :imageRef => config[node_details[hostname][:az]]['image_id'], + :flavorRef => config['flavor_id'], + :security_groups=>[role_details[:security_group]], + :user_data => Base64.encode64(multipart), + :metadata => metadata, + :key_name => config[:key_pair]) + + # wait for the server to become ACTIVE before proceeding + while (newserver.status != 'ACTIVE') do + print '.' + sleep 1 + # refresh the status + newserver.refresh + end + puts + + # refresh the config[:all_instances] with the newly built server + # TODO: we should be able to just add this server, instead of re-polling everything + Stack.get_all_instances(config, true) + + # refresh the chef_server details..we should have IPs now + if role_details[:chef_server] + Stack.set_chef_server(config, hostname) + Stack.generate_knife_rb(config) + end + + # attach a floating IP to this if we have one + if role_details[:floating_ips] && role_details[:floating_ips][p-1] + floating_ip = role_details[:floating_ips][p-1] + Logger.info "Attaching #{floating_ip} to #{hostname}\n" + # nova --os-region-name $REGION add-floating-ip $SERVER_NAME $FLOATING_IP + floating_ip_add = `nova --os-region-name #{node_details[hostname][:az]} add-floating-ip #{hostname} #{floating_ip}` + Logger.info floating_ip_add + end + + # refresh the secgroups ASAP + Stack.secgroup_sync(config) + + # run any post-install scripts, these are run from the current host, not the nodes + if role_details[:post_install_script] + # convert when we got passed to an absolute path + post_install_script_abs = File.realpath(config[:stackhome] + '/' + role_details[:post_install_script]) + post_install_cwd_abs = File.realpath(config[:stackhome] + '/' + role_details[:post_install_cwd]) + + # replace any tokens in the argument + public_ip = Stack.get_public_ip(config, hostname) + role_details[:post_install_args].sub!(%q!%PUBLIC_IP%!, public_ip) + # we system this, as they are can give live feed back + Logger.info "Executing '#{post_install_script_abs} #{role_details[:post_install_args]}' as the post_install_script" + system("cd #{post_install_cwd_abs} ; #{post_install_script_abs} #{role_details[:post_install_args]}") + end + else + Logger.info "Skipped role #{role}" + end + end + end + end + + def Stack.find_file(config, filename) + # find a file, using the standard path precedence + # 1) cwd + # 2) stackhome + # 3) gemhome/lib + dirs = [ './' ] + dirs.push(config[:stackhome]) + dirs.push(@@gemhome + '/lib') + + Logger.debug "find_file, looking for #{filename} in #{dirs}" + filename_fqp = '' + dirs.each do |dir| + fqp = dir + '/' + filename + Logger.debug "find_file: checking #{fqp}" + if File.file?(fqp) + Logger.debug "find_file: found #{fqp}!" + filename_fqp = File.expand_path(fqp) + end + end + + if filename_fqp.empty? + Logger.warn "couldn't find #{filename} in #{dirs}" + end + filename_fqp + end + end +