PageRenderTime 55ms CodeModel.GetById 22ms RepoModel.GetById 0ms app.codeStats 0ms

/mysql/lib/mysql_service/node.rb

https://github.com/talsalmona/vcap-services
Ruby | 516 lines | 442 code | 49 blank | 25 comment | 32 complexity | 0f2cb8383e00f56e703f84d852cc0257 MD5 | raw file
  1. # Copyright (c) 2009-2011 VMware, Inc.
  2. require "erb"
  3. require "fileutils"
  4. require "logger"
  5. require "pp"
  6. require "datamapper"
  7. require "uuidtools"
  8. require "mysql"
  9. require "open3"
  10. $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', '..', '..', 'base', 'lib')
  11. require 'base/node'
  12. require 'base/service_error'
  13. module VCAP
  14. module Services
  15. module Mysql
  16. class Node < VCAP::Services::Base::Node
  17. end
  18. end
  19. end
  20. end
  21. require "mysql_service/common"
  22. require "mysql_service/util"
  23. require "mysql_service/storage_quota"
  24. require "mysql_service/mysql_error"
  25. class VCAP::Services::Mysql::Node
  26. KEEP_ALIVE_INTERVAL = 15
  27. LONG_QUERY_INTERVAL = 1
  28. STORAGE_QUOTA_INTERVAL = 1
  29. include VCAP::Services::Mysql::Util
  30. include VCAP::Services::Mysql::Common
  31. include VCAP::Services::Mysql
  32. class ProvisionedService
  33. include DataMapper::Resource
  34. property :name, String, :key => true
  35. property :user, String, :required => true
  36. property :password, String, :required => true
  37. property :plan, Enum[:free], :required => true
  38. property :quota_exceeded, Boolean, :default => false
  39. end
  40. def initialize(options)
  41. super(options)
  42. @mysql_config = options[:mysql]
  43. @max_db_size = options[:max_db_size] * 1024 * 1024
  44. @max_long_query = options[:max_long_query]
  45. @max_long_tx = options[:max_long_tx]
  46. @mysqldump_bin = options[:mysqldump_bin]
  47. @gzip_bin = options[:gzip_bin]
  48. @mysql_bin = options[:mysql_bin]
  49. @connection = mysql_connect
  50. EM.add_periodic_timer(KEEP_ALIVE_INTERVAL) {mysql_keep_alive}
  51. EM.add_periodic_timer(@max_long_query.to_f/2) {kill_long_queries} if @max_long_query > 0
  52. EM.add_periodic_timer(@max_long_tx.to_f/2) {kill_long_transaction} if @max_long_tx > 0
  53. EM.add_periodic_timer(STORAGE_QUOTA_INTERVAL) {enforce_storage_quota}
  54. @base_dir = options[:base_dir]
  55. FileUtils.mkdir_p(@base_dir) if @base_dir
  56. DataMapper.setup(:default, options[:local_db])
  57. DataMapper::auto_upgrade!
  58. check_db_consistency()
  59. @available_storage = options[:available_storage] * 1024 * 1024
  60. @node_capacity = @available_storage
  61. ProvisionedService.all.each do |provisioned_service|
  62. @available_storage -= storage_for_service(provisioned_service)
  63. end
  64. @queries_served=0
  65. @qps_last_updated=0
  66. # initialize qps counter
  67. get_qps
  68. @long_queries_killed=0
  69. @long_tx_killed=0
  70. @provision_served=0
  71. @binding_served=0
  72. end
  73. def announcement
  74. a = {
  75. :available_storage => @available_storage
  76. }
  77. a
  78. end
  79. def check_db_consistency()
  80. db_list = []
  81. @connection.query('select db, user from db').each{|db, user| db_list.push([db, user])}
  82. ProvisionedService.all.each do |service|
  83. db, user = service.name, service.user
  84. if not db_list.include?([db, user]) then
  85. @logger.error("Node database inconsistent!!! db:user <#{db}:#{user}> not in mysql.")
  86. next
  87. end
  88. end
  89. end
  90. def storage_for_service(provisioned_service)
  91. case provisioned_service.plan
  92. when :free then @max_db_size
  93. else
  94. raise MysqlError.new(MysqlError::MYSQL_INVALID_PLAN, provisioned_service.plan)
  95. end
  96. end
  97. def mysql_connect
  98. host, user, password, port, socket = %w{host user pass port socket}.map { |opt| @mysql_config[opt] }
  99. 5.times do
  100. begin
  101. return Mysql.real_connect(host, user, password, 'mysql', port.to_i, socket)
  102. rescue Mysql::Error => e
  103. @logger.error("MySQL connection attempt failed: [#{e.errno}] #{e.error}")
  104. sleep(5)
  105. end
  106. end
  107. @logger.fatal("MySQL connection unrecoverable")
  108. shutdown
  109. exit
  110. end
  111. #keep connection alive, and check db liveness
  112. def mysql_keep_alive
  113. @connection.ping()
  114. rescue Mysql::Error => e
  115. @logger.error("MySQL connection lost: [#{e.errno}] #{e.error}")
  116. @connection = mysql_connect
  117. end
  118. def kill_long_queries
  119. process_list = @connection.list_processes
  120. process_list.each do |proc|
  121. thread_id, user, _, db, command, time, _, info = proc
  122. if (time.to_i >= @max_long_query) and (command == 'Query') and (user != 'root') then
  123. @connection.query("KILL QUERY " + thread_id)
  124. @logger.warn("Killed long query: user:#{user} db:#{db} time:#{time} info:#{info}")
  125. @long_queries_killed += 1
  126. end
  127. end
  128. rescue Mysql::Error => e
  129. @logger.error("MySQL error: [#{e.errno}] #{e.error}")
  130. end
  131. def kill_long_transaction
  132. query_str = "SELECT * from ("+
  133. " SELECT trx_started, id, user, db, info, TIME_TO_SEC(TIMEDIFF(NOW() , trx_started )) as active_time" +
  134. " FROM information_schema.INNODB_TRX t inner join information_schema.PROCESSLIST p " +
  135. " ON t.trx_mysql_thread_id = p.ID " +
  136. " WHERE trx_state='RUNNING' and user!='root' " +
  137. ") as inner_table " +
  138. "WHERE inner_table.active_time > #{@max_long_tx}"
  139. result = @connection.query(query_str)
  140. result.each do |trx|
  141. trx_started, id, user, db, info, active_time = trx
  142. @connection.query("KILL QUERY #{id}")
  143. @logger.warn("Kill long transaction: user:#{user} db:#{db} thread:#{id} info:#{info} active_time:#{active_time}")
  144. @long_tx_killed +=1
  145. end
  146. rescue => e
  147. @logger.error("Error during kill long transaction: #{e}.")
  148. end
  149. def provision(plan, credential=nil)
  150. provisioned_service = ProvisionedService.new
  151. if credential
  152. name, user, password = %w(name user password).map{|key| credential[key]}
  153. provisioned_service.name = name
  154. provisioned_service.user = user
  155. provisioned_service.password = password
  156. else
  157. provisioned_service.name = "d-#{UUIDTools::UUID.random_create.to_s}".gsub(/-/, '')
  158. provisioned_service.user = 'u' + generate_credential
  159. provisioned_service.password = 'p' + generate_credential
  160. end
  161. provisioned_service.plan = plan
  162. create_database(provisioned_service)
  163. if not provisioned_service.save
  164. @logger.error("Could not save entry: #{provisioned_service.errors.pretty_inspect}")
  165. raise MysqlError.new(MysqlError::MYSQL_LOCAL_DB_ERROR)
  166. end
  167. response = gen_credential(provisioned_service.name, provisioned_service.user, provisioned_service.password)
  168. @provision_served += 1
  169. return response
  170. rescue => e
  171. delete_database(provisioned_service)
  172. raise e
  173. end
  174. def unprovision(name, credentials)
  175. return if name.nil?
  176. @logger.debug("Unprovision database:#{name}, bindings: #{credentials.inspect}")
  177. provisioned_service = ProvisionedService.get(name)
  178. raise MysqlError.new(MysqlError::MYSQL_CONFIG_NOT_FOUND, name) if provisioned_service.nil?
  179. # TODO: validate that database files are not lingering
  180. # Delete all bindings, ignore not_found error since we are unprovision
  181. begin
  182. credentials.each{ |credential| unbind(credential)} if credentials
  183. rescue =>e
  184. # ignore
  185. end
  186. delete_database(provisioned_service)
  187. storage = storage_for_service(provisioned_service)
  188. @available_storage += storage
  189. if not provisioned_service.destroy
  190. @logger.error("Could not delete service: #{provisioned_service.errors.pretty_inspect}")
  191. raise MysqlError.new(MysqError::MYSQL_LOCAL_DB_ERROR)
  192. end
  193. @logger.debug("Successfully fulfilled unprovision request: #{name}")
  194. true
  195. end
  196. def bind(name, bind_opts, credential=nil)
  197. @logger.debug("Bind service for db:#{name}, bind_opts = #{bind_opts}")
  198. binding = nil
  199. begin
  200. service = ProvisionedService.get(name)
  201. raise MysqlError.new(MysqlError::MYSQL_CONFIG_NOT_FOUND, name) unless service
  202. # create new credential for binding
  203. binding = Hash.new
  204. if credential
  205. binding[:user] = credential["user"]
  206. binding[:password ]= credential["password"]
  207. else
  208. binding[:user] = 'u' + generate_credential
  209. binding[:password ]= 'p' + generate_credential
  210. end
  211. binding[:bind_opts] = bind_opts
  212. create_database_user(name, binding[:user], binding[:password])
  213. response = gen_credential(name, binding[:user], binding[:password])
  214. @logger.debug("Bind response: #{response.inspect}")
  215. @binding_served += 1
  216. return response
  217. rescue => e
  218. delete_database_user(binding[:user]) if binding
  219. raise e
  220. end
  221. end
  222. def unbind(credential)
  223. return if credential.nil?
  224. @logger.debug("Unbind service: #{credential.inspect}")
  225. name, user, bind_opts,passwd = %w(name user bind_opts password).map{|k| credential[k]}
  226. service = ProvisionedService.get(name)
  227. raise MysqlError.new(MysqlError::MYSQL_CONFIG_NOT_FOUND, name) unless service
  228. # validate the existence of credential, in case we delete a normal account because of a malformed credential
  229. res = @connection.query("SELECT * from mysql.user WHERE user='#{user}' AND password=PASSWORD('#{passwd}')")
  230. raise MysqlError.new(MysqlError::MYSQL_CRED_NOT_FOUND, credential.inspect) if res.num_rows()<=0
  231. delete_database_user(user)
  232. true
  233. end
  234. def create_database(provisioned_service)
  235. name, password, user = [:name, :password, :user].map { |field| provisioned_service.send(field) }
  236. begin
  237. start = Time.now
  238. @logger.debug("Creating: #{provisioned_service.pretty_inspect}")
  239. @connection.query("CREATE DATABASE #{name}")
  240. create_database_user(name, user, password)
  241. storage = storage_for_service(provisioned_service)
  242. raise MysqlError.new(MysqlError::MYSQL_DISK_FULL) if @available_storage < storage
  243. @available_storage -= storage
  244. @logger.debug("Done creating #{provisioned_service.pretty_inspect}. Took #{Time.now - start}.")
  245. rescue Mysql::Error => e
  246. @logger.warn("Could not create database: [#{e.errno}] #{e.error}")
  247. end
  248. end
  249. def create_database_user(name, user, password)
  250. @logger.info("Creating credentials: #{user}/#{password} for database #{name}")
  251. @connection.query("GRANT ALL ON #{name}.* to #{user}@'%' IDENTIFIED BY '#{password}'")
  252. @connection.query("GRANT ALL ON #{name}.* to #{user}@'localhost' IDENTIFIED BY '#{password}'")
  253. @connection.query("FLUSH PRIVILEGES")
  254. end
  255. def delete_database(provisioned_service)
  256. name, user = [:name, :user].map { |field| provisioned_service.send(field) }
  257. begin
  258. delete_database_user(user)
  259. @logger.info("Deleting database: #{name}")
  260. @connection.query("DROP DATABASE #{name}")
  261. rescue Mysql::Error => e
  262. @logger.fatal("Could not delete database: [#{e.errno}] #{e.error}")
  263. end
  264. end
  265. def delete_database_user(user)
  266. @logger.info("Delete user #{user}")
  267. @connection.query("DROP USER #{user}")
  268. @connection.query("DROP USER #{user}@'localhost'")
  269. kill_user_session(user)
  270. rescue Mysql::Error => e
  271. @logger.fatal("Could not delete user '#{user}': [#{e.errno}] #{e.error}")
  272. end
  273. def kill_user_session(user)
  274. @logger.info("Kill sessions of user: #{user}")
  275. begin
  276. process_list = @connection.list_processes
  277. process_list.each do |proc|
  278. thread_id, user_, _, db, command, time, _, info = proc
  279. if user_ == user then
  280. @connection.query("KILL #{thread_id}")
  281. @logger.info("Kill session: user:#{user} db:#{db}")
  282. end
  283. end
  284. rescue Mysql::Error => e
  285. # kill session failed error, only log it.
  286. @logger.error("Could not kill user session.:[#{e.errno}] #{e.error}")
  287. end
  288. end
  289. # restore a given instance using backup file.
  290. def restore(name, backup_path)
  291. @logger.debug("Restore db #{name} using backup at #{backup_path}")
  292. service = ProvisionedService.get(name)
  293. raise MysqlError.new(MysqlError::MYSQL_CONFIG_NOT_FOUND, name) unless service
  294. host, user, pass, port, socket = %w{host user pass port socket}.map { |opt| @mysql_config[opt] }
  295. path = File.join(backup_path, "#{name}.sql.gz")
  296. cmd ="#{@gzip_bin} -dc #{path}|" +
  297. "#{@mysql_bin} -h #{host} -P #{port} -u #{user} --password=#{pass}"
  298. cmd += " -S #{socket}" unless socket.nil?
  299. cmd += " #{name}"
  300. o, e, s = exe_cmd(cmd)
  301. if s.exitstatus == 0
  302. return true
  303. else
  304. return nil
  305. end
  306. rescue => e
  307. @logger.error("Error during restore #{e}")
  308. nil
  309. end
  310. # Disable all credentials and kill user sessions
  311. def disable_instance(prov_cred, binding_creds)
  312. @logger.debug("Disable instance #{prov_cred["name"]} request.")
  313. binding_creds << prov_cred
  314. binding_creds.each do |cred|
  315. unbind(cred)
  316. end
  317. true
  318. rescue => e
  319. @logger.warn(e)
  320. nil
  321. end
  322. # Dump db content into given path
  323. def dump_instance(prov_cred, binding_creds, dump_file_path)
  324. @logger.debug("Dump instance #{prov_cred["name"]} request.")
  325. name = prov_cred["name"]
  326. host, user, password, port, socket = %w{host user pass port socket}.map { |opt| @mysql_config[opt] }
  327. dump_file = File.join(dump_file_path, "#{name}.sql")
  328. @logger.info("Dump instance #{name} content to #{dump_file}")
  329. cmd = "#{@mysqldump_bin} -h #{host} -u #{user} --password=#{password} --single-transaction #{name} > #{dump_file}"
  330. o, e, s = exe_cmd(cmd)
  331. if s.exitstatus == 0
  332. return true
  333. else
  334. return nil
  335. end
  336. rescue => e
  337. @logger.warn(e)
  338. nil
  339. end
  340. # Provision and import dump files
  341. # Refer to #dump_instance
  342. def import_instance(prov_cred, binding_creds, dump_file_path, plan)
  343. @logger.debug("Import instance #{prov_cred["name"]} request.")
  344. @logger.info("Provision an instance with plan: #{plan} using data from #{prov_cred.inspect}")
  345. provision(plan, prov_cred)
  346. name = prov_cred["name"]
  347. import_file = File.join(dump_file_path, "#{name}.sql")
  348. host, user, password, port, socket = %w{host user pass port socket}.map { |opt| @mysql_config[opt] }
  349. @logger.info("Import data from #{import_file} to database #{name}")
  350. cmd = "#{@mysql_bin} --host=#{host} --user=#{user} --password=#{password} #{name} < #{import_file}"
  351. o, e, s = exe_cmd(cmd)
  352. if s.exitstatus == 0
  353. return true
  354. else
  355. return nil
  356. end
  357. rescue => e
  358. @logger.warn(e)
  359. nil
  360. end
  361. # Re-bind credentials
  362. # Refer to #disable_instance
  363. def enable_instance(prov_cred, binding_creds_hash)
  364. @logger.debug("Enable instance #{prov_cred["name"]} request.")
  365. name = prov_cred["name"]
  366. binding_creds_hash[name] = prov_cred
  367. binding_creds_hash.each do |k, v|
  368. cred = v["credentials"]
  369. binding_opts = v["binding_options"]
  370. bind(name, binding_opts, cred)
  371. end
  372. # Mysql don't need to modify binding info
  373. return [prov_cred, binding_creds_hash]
  374. rescue => e
  375. @logger.warn(e)
  376. []
  377. end
  378. # shell CMD wrapper and logger
  379. def exe_cmd(cmd, stdin=nil)
  380. @logger.debug("Execute shell cmd:[#{cmd}]")
  381. o, e, s = Open3.capture3(cmd, :stdin_data => stdin)
  382. if s.exitstatus == 0
  383. @logger.info("Execute cmd:[#{cmd}] successd.")
  384. else
  385. @logger.error("Execute cmd:[#{cmd}] failed. Stdin:[#{stdin}], stdout: [#{o}], stderr:[#{e}]")
  386. end
  387. return [o, e, s]
  388. end
  389. def varz_details()
  390. @logger.debug("Generate varz.")
  391. varz = {}
  392. # how many queries served since startup
  393. varz[:queries_since_startup] = get_queries_status
  394. # queries per second
  395. varz[:queries_per_second] = get_qps
  396. # disk usage per instance
  397. status = get_instance_status
  398. varz[:database_status] = status
  399. # node capacity
  400. varz[:node_storage_capacity] = @node_capacity
  401. varz[:node_storage_used] = @node_capacity - @available_storage
  402. # how many long queries and long txs are killed.
  403. varz[:long_queries_killed] = @long_queries_killed
  404. varz[:long_transactions_killed] = @long_tx_killed
  405. # how many provision/binding operations since startup.
  406. varz[:provision_served] = @provision_served
  407. varz[:binding_served] = @binding_served
  408. varz
  409. rescue => e
  410. @logger.error("Error during generate varz:"+e)
  411. {}
  412. end
  413. def get_queries_status()
  414. @logger.debug("Get mysql query status.")
  415. result = @connection.query("SHOW STATUS WHERE Variable_name ='QUERIES'")
  416. return 0 if result.num_rows == 0
  417. return result.fetch_row[1].to_i
  418. end
  419. def get_qps()
  420. @logger.debug("Calculate queries per seconds.")
  421. queries = get_queries_status
  422. ts = Time.now.to_i
  423. delta_t = (ts - @qps_last_updated).to_f
  424. qps = (queries - @queries_served)/delta_t
  425. @queries_served = queries
  426. @qps_last_updated = ts
  427. qps
  428. end
  429. def get_instance_status()
  430. @logger.debug("Get database instance status.")
  431. all_dbs =[]
  432. result = @connection.query('show databases')
  433. result.each {|db| all_dbs << db[0]}
  434. system_dbs = ['mysql', 'information_schema']
  435. sizes = @connection.query(
  436. 'SELECT table_schema "name",
  437. sum( data_length + index_length ) "size"
  438. FROM information_schema.TABLES
  439. GROUP BY table_schema')
  440. result = []
  441. db_with_tables = []
  442. sizes.each do |i|
  443. db= {}
  444. name, size = i
  445. next if system_dbs.include?(name)
  446. db_with_tables << name
  447. db[:name] = name
  448. db[:size] = size.to_i
  449. db[:max_size] = @max_db_size
  450. result << db
  451. end
  452. # handle empty db without table
  453. (all_dbs - db_with_tables - system_dbs ).each do |db|
  454. result << {:name => db, :size => 0, :max_size => @max_db_size}
  455. end
  456. result
  457. end
  458. def gen_credential(name, user, passwd)
  459. response = {
  460. "name" => name,
  461. "hostname" => @local_ip,
  462. "port" => @mysql_config['port'],
  463. "user" => user,
  464. "password" => passwd,
  465. }
  466. end
  467. end