PageRenderTime 27ms CodeModel.GetById 1ms RepoModel.GetById 0ms app.codeStats 0ms

/mysql/lib/mysql_service/node.rb

https://github.com/griddynamics/vcap-services
Ruby | 570 lines | 488 code | 51 blank | 31 comment | 32 complexity | fc4b41b252c6df9b7de5f6bf69c5119e 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. # mysql database name should start with alphabet character
  158. provisioned_service.name = 'd' + UUIDTools::UUID.random_create.to_s.gsub(/-/, '')
  159. provisioned_service.user = 'u' + generate_credential
  160. provisioned_service.password = 'p' + generate_credential
  161. end
  162. provisioned_service.plan = plan
  163. create_database(provisioned_service)
  164. if not provisioned_service.save
  165. @logger.error("Could not save entry: #{provisioned_service.errors.inspect}")
  166. raise MysqlError.new(MysqlError::MYSQL_LOCAL_DB_ERROR)
  167. end
  168. response = gen_credential(provisioned_service.name, provisioned_service.user, provisioned_service.password)
  169. @provision_served += 1
  170. return response
  171. rescue => e
  172. delete_database(provisioned_service)
  173. raise e
  174. end
  175. def unprovision(name, credentials)
  176. return if name.nil?
  177. @logger.debug("Unprovision database:#{name}, bindings: #{credentials.inspect}")
  178. provisioned_service = ProvisionedService.get(name)
  179. raise MysqlError.new(MysqlError::MYSQL_CONFIG_NOT_FOUND, name) if provisioned_service.nil?
  180. # TODO: validate that database files are not lingering
  181. # Delete all bindings, ignore not_found error since we are unprovision
  182. begin
  183. credentials.each{ |credential| unbind(credential)} if credentials
  184. rescue =>e
  185. # ignore
  186. end
  187. delete_database(provisioned_service)
  188. storage = storage_for_service(provisioned_service)
  189. @available_storage += storage
  190. if not provisioned_service.destroy
  191. @logger.error("Could not delete service: #{provisioned_service.errors.inspect}")
  192. raise MysqlError.new(MysqError::MYSQL_LOCAL_DB_ERROR)
  193. end
  194. @logger.debug("Successfully fulfilled unprovision request: #{name}")
  195. true
  196. end
  197. def bind(name, bind_opts, credential=nil)
  198. @logger.debug("Bind service for db:#{name}, bind_opts = #{bind_opts}")
  199. binding = nil
  200. begin
  201. service = ProvisionedService.get(name)
  202. raise MysqlError.new(MysqlError::MYSQL_CONFIG_NOT_FOUND, name) unless service
  203. # create new credential for binding
  204. binding = Hash.new
  205. if credential
  206. binding[:user] = credential["user"]
  207. binding[:password ]= credential["password"]
  208. else
  209. binding[:user] = 'u' + generate_credential
  210. binding[:password ]= 'p' + generate_credential
  211. end
  212. binding[:bind_opts] = bind_opts
  213. create_database_user(name, binding[:user], binding[:password])
  214. response = gen_credential(name, binding[:user], binding[:password])
  215. @logger.debug("Bind response: #{response.inspect}")
  216. @binding_served += 1
  217. return response
  218. rescue => e
  219. delete_database_user(binding[:user]) if binding
  220. raise e
  221. end
  222. end
  223. def unbind(credential)
  224. return if credential.nil?
  225. @logger.debug("Unbind service: #{credential.inspect}")
  226. name, user, bind_opts,passwd = %w(name user bind_opts password).map{|k| credential[k]}
  227. service = ProvisionedService.get(name)
  228. raise MysqlError.new(MysqlError::MYSQL_CONFIG_NOT_FOUND, name) unless service
  229. # validate the existence of credential, in case we delete a normal account because of a malformed credential
  230. res = @connection.query("SELECT * from mysql.user WHERE user='#{user}' AND password=PASSWORD('#{passwd}')")
  231. raise MysqlError.new(MysqlError::MYSQL_CRED_NOT_FOUND, credential.inspect) if res.num_rows()<=0
  232. delete_database_user(user)
  233. true
  234. end
  235. def create_database(provisioned_service)
  236. name, password, user = [:name, :password, :user].map { |field| provisioned_service.send(field) }
  237. begin
  238. start = Time.now
  239. @logger.debug("Creating: #{provisioned_service.inspect}")
  240. @connection.query("CREATE DATABASE #{name}")
  241. create_database_user(name, user, password)
  242. storage = storage_for_service(provisioned_service)
  243. @available_storage -= storage
  244. @logger.debug("Done creating #{provisioned_service.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. # revoke write and lock privileges to prevent race with drop database.
  295. @connection.query("UPDATE db SET insert_priv='N', create_priv='N',
  296. update_priv='N', lock_tables_priv='N' WHERE Db='#{name}'")
  297. @connection.query("FLUSH PRIVILEGES")
  298. kill_database_session(name)
  299. # mysql can't delete tables that not in dump file.
  300. # recreate the database to prevent leave unclean tables after restore.
  301. @connection.query("DROP DATABASE #{name}")
  302. @connection.query("CREATE DATABASE #{name}")
  303. # restore privileges.
  304. @connection.query("UPDATE db SET insert_priv='Y', create_priv='Y',
  305. update_priv='Y', lock_tables_priv='Y' WHERE Db='#{name}'")
  306. @connection.query("FLUSH PRIVILEGES")
  307. host, user, pass, port, socket = %w{host user pass port socket}.map { |opt| @mysql_config[opt] }
  308. path = File.join(backup_path, "#{name}.sql.gz")
  309. cmd ="#{@gzip_bin} -dc #{path}|" +
  310. "#{@mysql_bin} -h #{host} -P #{port} -u #{user} --password=#{pass}"
  311. cmd += " -S #{socket}" unless socket.nil?
  312. cmd += " #{name}"
  313. o, e, s = exe_cmd(cmd)
  314. if s.exitstatus == 0
  315. return true
  316. else
  317. return nil
  318. end
  319. rescue => e
  320. @logger.error("Error during restore #{e}")
  321. nil
  322. end
  323. # Disable all credentials and kill user sessions
  324. def disable_instance(prov_cred, binding_creds)
  325. @logger.debug("Disable instance #{prov_cred["name"]} request.")
  326. binding_creds << prov_cred
  327. binding_creds.each do |cred|
  328. unbind(cred)
  329. end
  330. true
  331. rescue => e
  332. @logger.warn(e)
  333. nil
  334. end
  335. # Dump db content into given path
  336. def dump_instance(prov_cred, binding_creds, dump_file_path)
  337. @logger.debug("Dump instance #{prov_cred["name"]} request.")
  338. name = prov_cred["name"]
  339. host, user, password, port, socket = %w{host user pass port socket}.map { |opt| @mysql_config[opt] }
  340. dump_file = File.join(dump_file_path, "#{name}.sql")
  341. @logger.info("Dump instance #{name} content to #{dump_file}")
  342. cmd = "#{@mysqldump_bin} -h #{host} -u #{user} --password=#{password} --single-transaction #{name} > #{dump_file}"
  343. o, e, s = exe_cmd(cmd)
  344. if s.exitstatus == 0
  345. return true
  346. else
  347. return nil
  348. end
  349. rescue => e
  350. @logger.warn(e)
  351. nil
  352. end
  353. # Provision and import dump files
  354. # Refer to #dump_instance
  355. def import_instance(prov_cred, binding_creds, dump_file_path, plan)
  356. @logger.debug("Import instance #{prov_cred["name"]} request.")
  357. @logger.info("Provision an instance with plan: #{plan} using data from #{prov_cred.inspect}")
  358. provision(plan, prov_cred)
  359. name = prov_cred["name"]
  360. import_file = File.join(dump_file_path, "#{name}.sql")
  361. host, user, password, port, socket = %w{host user pass port socket}.map { |opt| @mysql_config[opt] }
  362. @logger.info("Import data from #{import_file} to database #{name}")
  363. cmd = "#{@mysql_bin} --host=#{host} --user=#{user} --password=#{password} #{name} < #{import_file}"
  364. o, e, s = exe_cmd(cmd)
  365. if s.exitstatus == 0
  366. return true
  367. else
  368. return nil
  369. end
  370. rescue => e
  371. @logger.warn(e)
  372. nil
  373. end
  374. # Re-bind credentials
  375. # Refer to #disable_instance
  376. def enable_instance(prov_cred, binding_creds_hash)
  377. @logger.debug("Enable instance #{prov_cred["name"]} request.")
  378. name = prov_cred["name"]
  379. bind(name, nil, prov_cred)
  380. binding_creds_hash.each do |k, v|
  381. cred = v["credentials"]
  382. binding_opts = v["binding_options"]
  383. bind(name, binding_opts, cred)
  384. end
  385. # Mysql don't need to modify binding info
  386. return [prov_cred, binding_creds_hash]
  387. rescue => e
  388. @logger.warn(e)
  389. []
  390. end
  391. # shell CMD wrapper and logger
  392. def exe_cmd(cmd, stdin=nil)
  393. @logger.debug("Execute shell cmd:[#{cmd}]")
  394. o, e, s = Open3.capture3(cmd, :stdin_data => stdin)
  395. if s.exitstatus == 0
  396. @logger.info("Execute cmd:[#{cmd}] successd.")
  397. else
  398. @logger.error("Execute cmd:[#{cmd}] failed. Stdin:[#{stdin}], stdout: [#{o}], stderr:[#{e}]")
  399. end
  400. return [o, e, s]
  401. end
  402. def varz_details()
  403. @logger.debug("Generate varz.")
  404. varz = {}
  405. # how many queries served since startup
  406. varz[:queries_since_startup] = get_queries_status
  407. # queries per second
  408. varz[:queries_per_second] = get_qps
  409. # disk usage per instance
  410. status = get_instance_status
  411. varz[:database_status] = status
  412. # node capacity
  413. varz[:node_storage_capacity] = @node_capacity
  414. varz[:node_storage_used] = @node_capacity - @available_storage
  415. # how many long queries and long txs are killed.
  416. varz[:long_queries_killed] = @long_queries_killed
  417. varz[:long_transactions_killed] = @long_tx_killed
  418. # how many provision/binding operations since startup.
  419. varz[:provision_served] = @provision_served
  420. varz[:binding_served] = @binding_served
  421. varz
  422. rescue => e
  423. @logger.error("Error during generate varz: #{e}")
  424. {}
  425. end
  426. def healthz_details()
  427. healthz = {:self => "ok"}
  428. begin
  429. @connection.query("SHOW DATABASES")
  430. rescue => e
  431. @logger.error("Error get database list: #{e}")
  432. healthz[:self] = "fail"
  433. return healthz
  434. end
  435. begin
  436. ProvisionedService.all.each do |instance|
  437. healthz[instance.name.to_sym] = get_instance_healthz(instance)
  438. end
  439. rescue => e
  440. @logger.error("Error get instance list: #{e}")
  441. healthz[:self] = "fail"
  442. end
  443. healthz
  444. end
  445. def get_instance_healthz(instance)
  446. res = "ok"
  447. host, port, socket = %w{host port socket}.map { |opt| @mysql_config[opt] }
  448. begin
  449. conn = Mysql.real_connect(host, instance.user, instance.password, instance.name, port.to_i, socket)
  450. conn.query("SHOW TABLES")
  451. rescue => e
  452. @logger.error("Error get tables of #{instance.name}: #{e}")
  453. res = "fail"
  454. ensure
  455. begin
  456. conn.close if conn
  457. rescue => e1
  458. #ignore
  459. end
  460. end
  461. res
  462. end
  463. def get_queries_status()
  464. @logger.debug("Get mysql query status.")
  465. result = @connection.query("SHOW STATUS WHERE Variable_name ='QUERIES'")
  466. return 0 if result.num_rows == 0
  467. return result.fetch_row[1].to_i
  468. end
  469. def get_qps()
  470. @logger.debug("Calculate queries per seconds.")
  471. queries = get_queries_status
  472. ts = Time.now.to_i
  473. delta_t = (ts - @qps_last_updated).to_f
  474. qps = (queries - @queries_served)/delta_t
  475. @queries_served = queries
  476. @qps_last_updated = ts
  477. qps
  478. end
  479. def get_instance_status()
  480. @logger.debug("Get database instance status.")
  481. all_dbs =[]
  482. result = @connection.query('show databases')
  483. result.each {|db| all_dbs << db[0]}
  484. system_dbs = ['mysql', 'information_schema']
  485. sizes = @connection.query(
  486. 'SELECT table_schema "name",
  487. sum( data_length + index_length ) "size"
  488. FROM information_schema.TABLES
  489. GROUP BY table_schema')
  490. result = []
  491. db_with_tables = []
  492. sizes.each do |i|
  493. db= {}
  494. name, size = i
  495. next if system_dbs.include?(name)
  496. db_with_tables << name
  497. db[:name] = name
  498. db[:size] = size.to_i
  499. db[:max_size] = @max_db_size
  500. result << db
  501. end
  502. # handle empty db without table
  503. (all_dbs - db_with_tables - system_dbs ).each do |db|
  504. result << {:name => db, :size => 0, :max_size => @max_db_size}
  505. end
  506. result
  507. end
  508. def gen_credential(name, user, passwd)
  509. response = {
  510. "name" => name,
  511. "hostname" => @local_ip,
  512. "host" => @local_ip,
  513. "port" => @mysql_config['port'],
  514. "user" => user,
  515. "username" => user,
  516. "password" => passwd,
  517. }
  518. end
  519. end