cloud.rb 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. #!/usr/bin/env ruby
  2. require 'thor'
  3. require 'json'
  4. require 'yaml'
  5. require 'securerandom'
  6. require 'fileutils'
  7. require 'parseconfig'
  8. require 'open3'
  9. # Don't buffer output to the client
  10. STDOUT.sync = true
  11. STDERR.sync = true
  12. SCRIPT_DIR = File.expand_path(File.dirname(__FILE__))
  13. module OpenShift
  14. module Ops
  15. # WARNING: we do not currently support environments with hyphens in the name
  16. SUPPORTED_ENVS = %w(prod stg int tint kint test jint)
  17. class GceHelper
  18. def self.list_hosts()
  19. cmd = "#{SCRIPT_DIR}/inventory/gce/gce.py --list"
  20. hosts = %x[#{cmd} 2>&1]
  21. raise "Error: failed to list hosts\n#{hosts}" unless $?.exitstatus == 0
  22. return JSON.parse(hosts)
  23. end
  24. def self.get_host_details(host)
  25. cmd = "#{SCRIPT_DIR}/inventory/gce/gce.py --host #{host}"
  26. details = %x[#{cmd} 2>&1]
  27. raise "Error: failed to get host details\n#{details}" unless $?.exitstatus == 0
  28. retval = JSON.parse(details)
  29. # Convert OpenShift specific tags to entries
  30. retval['gce_tags'].each do |tag|
  31. if tag =~ /\Ahost-type-([\w\d-]+)\z/
  32. retval['host-type'] = $1
  33. end
  34. if tag =~ /\Aenv-([\w\d]+)\z/
  35. retval['env'] = $1
  36. end
  37. end
  38. return retval
  39. end
  40. def self.generate_env_tag(env)
  41. return "env-#{env}"
  42. end
  43. def self.generate_env_tag_name(env)
  44. return "tag_#{generate_env_tag(env)}"
  45. end
  46. def self.generate_host_type_tag(host_type)
  47. return "host-type-#{host_type}"
  48. end
  49. def self.generate_host_type_tag_name(host_type)
  50. return "tag_#{generate_host_type_tag(host_type)}"
  51. end
  52. def self.generate_env_host_type_tag(env, host_type)
  53. return "env-host-type-#{env}-#{host_type}"
  54. end
  55. def self.generate_env_host_type_tag_name(env, host_type)
  56. return "tag_#{generate_env_host_type_tag(env, host_type)}"
  57. end
  58. end
  59. class LaunchHelper
  60. def self.expand_name(name)
  61. return [name] unless name =~ /^([a-zA-Z0-9\-]+)\{(\d+)-(\d+)\}$/
  62. # Regex matched, so grab the values
  63. start_num = $2
  64. end_num = $3
  65. retval = []
  66. start_num.upto(end_num) do |i|
  67. retval << "#{$1}#{i}"
  68. end
  69. return retval
  70. end
  71. def self.get_gce_host_types()
  72. return Dir.glob("#{SCRIPT_DIR}/playbooks/gce/*").map { |d| File.basename(d) }
  73. end
  74. end
  75. class AnsibleHelper
  76. attr_accessor :inventory, :extra_vars, :verbosity, :pipelining
  77. def initialize(extra_vars={}, inventory=nil)
  78. @extra_vars = extra_vars
  79. @verbosity = '-vvvv'
  80. @pipelining = true
  81. end
  82. def all_eof(files)
  83. files.find { |f| !f.eof }.nil?
  84. end
  85. def run_playbook(playbook)
  86. @inventory = 'inventory/hosts' if @inventory.nil?
  87. # This is used instead of passing in the json on the cli to avoid quoting problems
  88. tmpfile = Tempfile.open('extra_vars') { |f| f.write(@extra_vars.to_json); f}
  89. cmds = []
  90. #cmds << 'set -x'
  91. cmds << %Q[export ANSIBLE_FILTER_PLUGINS="#{Dir.pwd}/filter_plugins"]
  92. # We need this for launching instances, otherwise conflicting keys and what not kill it
  93. cmds << %q[export ANSIBLE_TRANSPORT="ssh"]
  94. cmds << %q[export ANSIBLE_SSH_ARGS="-o ForwardAgent=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"]
  95. # We need pipelining off so that we can do sudo to enable the root account
  96. cmds << %Q[export ANSIBLE_SSH_PIPELINING='#{@pipelining.to_s}']
  97. cmds << %Q[time -p ansible-playbook -i #{@inventory} #{@verbosity} #{playbook} --extra-vars '@#{tmpfile.path}']
  98. cmd = cmds.join(' ; ')
  99. pid = spawn(cmd, :out => $stdout, :err => $stderr, :close_others => true)
  100. _, state = Process.wait2(pid)
  101. if 0 != state.exitstatus
  102. raise %Q[Warning failed with exit code: #{state.exitstatus}
  103. #{cmd}
  104. extra_vars: #{@extra_vars.to_json}
  105. ]
  106. end
  107. ensure
  108. tmpfile.unlink if tmpfile
  109. end
  110. def merge_extra_vars_file(file)
  111. vars = YAML.load_file(file)
  112. @extra_vars.merge!(vars)
  113. end
  114. def self.for_gce
  115. ah = AnsibleHelper.new
  116. # GCE specific configs
  117. gce_ini = "#{SCRIPT_DIR}/inventory/gce/gce.ini"
  118. config = ParseConfig.new(gce_ini)
  119. if config['gce']['gce_project_id'].to_s.empty?
  120. raise %Q['gce_project_id' not set in #{gce_ini}]
  121. end
  122. ah.extra_vars['gce_project_id'] = config['gce']['gce_project_id']
  123. if config['gce']['gce_service_account_pem_file_path'].to_s.empty?
  124. raise %Q['gce_service_account_pem_file_path' not set in #{gce_ini}]
  125. end
  126. ah.extra_vars['gce_pem_file'] = config['gce']['gce_service_account_pem_file_path']
  127. if config['gce']['gce_service_account_email_address'].to_s.empty?
  128. raise %Q['gce_service_account_email_address' not set in #{gce_ini}]
  129. end
  130. ah.extra_vars['gce_service_account_email'] = config['gce']['gce_service_account_email_address']
  131. ah.inventory = 'inventory/gce/gce.py'
  132. return ah
  133. end
  134. def ignore_bug_6407
  135. puts
  136. puts %q[ .---- Spurious warning "It is unnecessary to use '{{' in loops" (ansible bug 6407) ----.]
  137. puts %q[ V V]
  138. end
  139. end
  140. class GceCommand < Thor
  141. option :type, :required => true, :enum => LaunchHelper.get_gce_host_types,
  142. :desc => 'The host type of the new instances.'
  143. option :env, :required => true, :aliases => '-e', :enum => OpenShift::Ops::SUPPORTED_ENVS,
  144. :desc => 'The environment of the new instances.'
  145. option :count, :default => 1, :aliases => '-c', :type => :numeric,
  146. :desc => 'The number of instances to create'
  147. option :tag, :type => :array,
  148. :desc => 'The tag(s) to add to the new instances. Allowed characters are letters, numbers, and hyphens.'
  149. desc "launch", "Launches instances."
  150. def launch()
  151. # Expand all of the instance names so that we have a complete array
  152. names = []
  153. options[:count].times { names << "#{options[:env]}-#{options[:type]}-#{SecureRandom.hex(5)}" }
  154. ah = AnsibleHelper.for_gce()
  155. # GCE specific configs
  156. ah.extra_vars['oo_new_inst_names'] = names
  157. ah.extra_vars['oo_new_inst_tags'] = options[:tag]
  158. ah.extra_vars['oo_env'] = options[:env]
  159. # Add a created by tag
  160. ah.extra_vars['oo_new_inst_tags'] = [] if ah.extra_vars['oo_new_inst_tags'].nil?
  161. ah.extra_vars['oo_new_inst_tags'] << "created-by-#{ENV['USER']}"
  162. ah.extra_vars['oo_new_inst_tags'] << GceHelper.generate_env_tag(options[:env])
  163. ah.extra_vars['oo_new_inst_tags'] << GceHelper.generate_host_type_tag(options[:type])
  164. ah.extra_vars['oo_new_inst_tags'] << GceHelper.generate_env_host_type_tag(options[:env], options[:type])
  165. puts
  166. puts 'Creating instance(s) in GCE...'
  167. ah.ignore_bug_6407
  168. ah.run_playbook("playbooks/gce/#{options[:type]}/launch.yml")
  169. end
  170. option :name, :required => false, :type => :string,
  171. :desc => 'The name of the instance to configure.'
  172. option :env, :required => false, :aliases => '-e', :enum => OpenShift::Ops::SUPPORTED_ENVS,
  173. :desc => 'The environment of the new instances.'
  174. option :type, :required => false, :enum => LaunchHelper.get_gce_host_types,
  175. :desc => 'The type of the instances to configure.'
  176. desc "config", 'Configures instances.'
  177. def config()
  178. ah = AnsibleHelper.for_gce()
  179. abort 'Error: you can\'t specify both --name and --type' unless options[:type].nil? || options[:name].nil?
  180. abort 'Error: you can\'t specify both --name and --env' unless options[:env].nil? || options[:name].nil?
  181. host_type = nil
  182. if options[:name]
  183. details = GceHelper.get_host_details(options[:name])
  184. ah.extra_vars['oo_host_group_exp'] = options[:name]
  185. ah.extra_vars['oo_env'] = details['env']
  186. host_type = details['host-type']
  187. elsif options[:type] && options[:env]
  188. oo_env_host_type_tag = GceHelper.generate_env_host_type_tag_name(options[:env], options[:type])
  189. ah.extra_vars['oo_host_group_exp'] = "groups['#{oo_env_host_type_tag}']"
  190. ah.extra_vars['oo_env'] = options[:env]
  191. host_type = options[:type]
  192. else
  193. abort 'Error: you need to specify either --name or (--type and --env)'
  194. end
  195. puts
  196. puts "Configuring #{options[:type]} instance(s) in GCE..."
  197. ah.ignore_bug_6407
  198. ah.run_playbook("playbooks/gce/#{host_type}/config.yml")
  199. end
  200. option :name, :required => false, :type => :string,
  201. :desc => 'The name of the instance to terminate.'
  202. option :env, :required => false, :aliases => '-e', :enum => OpenShift::Ops::SUPPORTED_ENVS,
  203. :desc => 'The environment of the new instances.'
  204. option :type, :required => false, :enum => LaunchHelper.get_gce_host_types,
  205. :desc => 'The type of the instances to configure.'
  206. option :confirm, :required => false, :type => :boolean,
  207. :desc => 'Terminate without interactive confirmation'
  208. desc "terminate", 'Terminate instances'
  209. def terminate()
  210. ah = AnsibleHelper.for_gce()
  211. abort 'Error: you can\'t specify both --name and --type' unless options[:type].nil? || options[:name].nil?
  212. abort 'Error: you can\'t specify both --name and --env' unless options[:env].nil? || options[:name].nil?
  213. host_type = nil
  214. if options[:name]
  215. details = GceHelper.get_host_details(options[:name])
  216. ah.extra_vars['oo_host_group_exp'] = options[:name]
  217. ah.extra_vars['oo_env'] = details['env']
  218. host_type = details['host-type']
  219. elsif options[:type] && options[:env]
  220. oo_env_host_type_tag = GceHelper.generate_env_host_type_tag_name(options[:env], options[:type])
  221. ah.extra_vars['oo_host_group_exp'] = "groups['#{oo_env_host_type_tag}']"
  222. ah.extra_vars['oo_env'] = options[:env]
  223. host_type = options[:type]
  224. else
  225. abort 'Error: you need to specify either --name or (--type and --env)'
  226. end
  227. puts
  228. puts "Terminating #{options[:type]} instance(s) in GCE..."
  229. ah.ignore_bug_6407
  230. ah.run_playbook("playbooks/gce/#{host_type}/terminate.yml")
  231. end
  232. desc "list", "Lists instances."
  233. def list()
  234. hosts = GceHelper.list_hosts()
  235. data = {}
  236. hosts.each do |key,value|
  237. value.each { |h| (data[h] ||= []) << key }
  238. end
  239. puts
  240. puts "Instances"
  241. puts "---------"
  242. data.keys.sort.each { |k| puts " #{k}" }
  243. puts
  244. end
  245. option :file, :required => true, :type => :string,
  246. :desc => 'The name of the file to copy.'
  247. option :dest, :required => false, :type => :string,
  248. :desc => 'A relative path where files are written to.'
  249. desc "scp_from", "scp files from an instance"
  250. def scp_from(*ssh_ops, host)
  251. if host =~ /^([\w\d_.-]+)@([\w\d-_.]+)$/
  252. user = $1
  253. host = $2
  254. end
  255. path_to_file = options['file']
  256. dest = options['dest']
  257. details = GceHelper.get_host_details(host)
  258. abort "\nError: Instance [#{host}] is not RUNNING\n\n" unless details['gce_status'] == 'RUNNING'
  259. cmd = "scp #{ssh_ops.join(' ')}"
  260. if user.nil?
  261. cmd += " "
  262. else
  263. cmd += " #{user}@"
  264. end
  265. if dest.nil?
  266. download = File.join(Dir.pwd, 'download')
  267. FileUtils.mkdir_p(download) unless File.exists?(download)
  268. cmd += "#{details['gce_public_ip']}:#{path_to_file} download/"
  269. else
  270. cmd += "#{details['gce_public_ip']}:#{path_to_file} #{File.expand_path(dest)}"
  271. end
  272. exec(cmd)
  273. end
  274. desc "ssh", "Ssh to an instance"
  275. def ssh(*ssh_ops, host)
  276. puts host
  277. if host =~ /^([\w\d_.-]+)@([\w\d-_.]+)/
  278. user = $1
  279. host = $2
  280. end
  281. puts "user=#{user}"
  282. puts "host=#{host}"
  283. details = GceHelper.get_host_details(host)
  284. abort "\nError: Instance [#{host}] is not RUNNING\n\n" unless details['gce_status'] == 'RUNNING'
  285. cmd = "ssh #{ssh_ops.join(' ')}"
  286. if user.nil?
  287. cmd += " "
  288. else
  289. cmd += " #{user}@"
  290. end
  291. cmd += "#{details['gce_public_ip']}"
  292. exec(cmd)
  293. end
  294. option :name, :required => true, :aliases => '-n', :type => :string,
  295. :desc => 'The name of the instance.'
  296. desc 'details', 'Displays details about an instance.'
  297. def details()
  298. name = options[:name]
  299. details = GceHelper.get_host_details(name)
  300. key_size = details.keys.max_by { |k| k.size }.size
  301. header = "Details for #{name}"
  302. puts
  303. puts header
  304. header.size.times { print '-' }
  305. puts
  306. details.each { |k,v| printf("%#{key_size + 2}s: %s\n", k, v) }
  307. puts
  308. end
  309. desc 'types', 'Displays instance types'
  310. def types()
  311. puts
  312. puts "Available Host Types"
  313. puts "--------------------"
  314. LaunchHelper.get_gce_host_types.each { |t| puts " #{t}" }
  315. puts
  316. end
  317. end
  318. class CloudCommand < Thor
  319. desc 'gce', 'Manages Google Compute Engine assets'
  320. subcommand "gce", GceCommand
  321. end
  322. end
  323. end
  324. if __FILE__ == $0
  325. Dir.chdir(SCRIPT_DIR) do
  326. # Kick off thor
  327. OpenShift::Ops::CloudCommand.start(ARGV)
  328. end
  329. end