fluent-plugin-sqlquery-ssh 0.1.10

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 783745761f7b0874235b8e93946c8f09845794ad
4
+ data.tar.gz: 736147289cd85f28d7e8eda154c229d2b2d244ab
5
+ SHA512:
6
+ metadata.gz: 82b1ce653d51a8e33138fa5d0336b69366a749b5e9c8a730f35dbb5be57ff4ce01aab4d28f38a5e80b030957960415e78e189aa3f6cfae994c1ab7134a7041f2
7
+ data.tar.gz: e3ceb2f54ffab7efa67d100e8a1e533bbb83faf04b3d663b9d31adabbafc0faaec47c063c29e065381cb8f264092e3a1732db41f03c44ca1e33955016966bfff
@@ -0,0 +1,50 @@
1
+ *.gem
2
+ *.rbc
3
+ /.config
4
+ /coverage/
5
+ /InstalledFiles
6
+ /pkg/
7
+ /spec/reports/
8
+ /spec/examples.txt
9
+ /test/tmp/
10
+ /test/version_tmp/
11
+ /tmp/
12
+
13
+ # Used by dotenv library to load environment variables.
14
+ # .env
15
+
16
+ ## Specific to RubyMotion:
17
+ .dat*
18
+ .repl_history
19
+ build/
20
+ *.bridgesupport
21
+ build-iPhoneOS/
22
+ build-iPhoneSimulator/
23
+
24
+ ## Specific to RubyMotion (use of CocoaPods):
25
+ #
26
+ # We recommend against adding the Pods directory to your .gitignore. However
27
+ # you should judge for yourself, the pros and cons are mentioned at:
28
+ # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
29
+ #
30
+ # vendor/Pods/
31
+
32
+ ## Documentation cache and generated files:
33
+ /.yardoc/
34
+ /_yardoc/
35
+ /doc/
36
+ /rdoc/
37
+
38
+ ## Environment normalization:
39
+ /.bundle/
40
+ /vendor/bundle
41
+ /lib/bundler/man/
42
+
43
+ # for a library or gem, you might want to ignore these files since the code is
44
+ # intended to run in multiple environments; otherwise, check them in:
45
+ # Gemfile.lock
46
+ # .ruby-version
47
+ # .ruby-gemset
48
+
49
+ # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
50
+ .rvmrc
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in fluent-plugin-munin.gemspec
4
+ gemspec
@@ -0,0 +1,14 @@
1
+ Copyright (c) 2012- Kentaro Yoshida
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
14
+
@@ -0,0 +1,43 @@
1
+
2
+ ===========================
3
+
4
+ Fluentd Input plugin to execute mysql query and fetch rows across multiple dbs based on an initial shard query returning array of host/db to hit
5
+
6
+ ## Installation
7
+
8
+ install with gem or fluent-gem command as:
9
+
10
+ ```
11
+ # for fluentd
12
+ $ fluentd-gem install fluent-plugin-sqlquery-ssh
13
+
14
+ # for td-agent
15
+ $ sudo /usr/lib64/fluent/ruby/bin/fluent-gem install fluent-plugin-sqlquery-ssh
16
+ ```
17
+
18
+ ## Configuration
19
+
20
+ ### Config Sample
21
+ `````
22
+ <source>
23
+ type sqlquery_ssh
24
+ shard_map_query SELECT database_host, group_concat(database_name) AS database_name FROM cust_customer WHERE status = 'customer' AND collect_stats=1 GROUP BY database_host order by database_host DESC;
25
+ query SELECT database() as customer from invoice
26
+ host system-reporting.db.prd.us-east-silo.ls #only use for reporting slaves
27
+ ssh_gateway mgmt.us-east-silo.lightspeedapp.com
28
+ skip_shards [""] #currently broken. use shard query to reduce set.
29
+ ssh_username #user name with jumphost access
30
+ db_username #reporting db username (do not use this on prod other than reporting)
31
+ db_password #the db password for the user above.
32
+ nest_result no #whether to nest the result set
33
+ nest_key data #key to nest
34
+ row_count no #add row count to the return
35
+ row_count_key row_count
36
+ tag debug #the matching tag
37
+ </source>
38
+
39
+ <match debug*>
40
+ type stdout
41
+ </match>
42
+ `````
43
+
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+ Rake::TestTask.new(:test) do |test|
4
+ test.libs << 'lib' << 'test'
5
+ test.pattern = 'test/**/test_*.rb'
6
+ test.verbose = true
7
+ end
8
+
9
+ task :default => :test
10
+
@@ -0,0 +1,23 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = "fluent-plugin-sqlquery-ssh"
6
+ s.version = "0.1.10"
7
+ s.license = "Apache-2.0"
8
+ s.authors = "Niall Brown"
9
+ s.email = "niall.brown@lightspeedretail.com"
10
+ # s.homepage = "https://github.com/y-ken/fluent-plugin-mysql-query-ssh"
11
+ s.summary = %q{Fluentd Input plugin to execute mysql query and fetch rows. It is useful for stationary interval metrics measurement.}
12
+
13
+ s.files = `git ls-files`.split("\n")
14
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
15
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
16
+ s.require_paths = ["lib"]
17
+
18
+ s.add_development_dependency "rake", ['~> 10.0', '>= 10.0.4']
19
+ s.add_development_dependency "test-unit", ['~> 3.1', '>= 3.1.0']
20
+ s.add_runtime_dependency "fluentd", [">= 0.10.30", "< 2"]
21
+ s.add_runtime_dependency "mysql2", "~> 0.3.11"
22
+ s.add_runtime_dependency "net-ssh-gateway", ['~> 1.2', '>= 1.2.0']
23
+ end
@@ -0,0 +1,259 @@
1
+ require 'fluent/plugin/input'
2
+ require 'fluent/config/error'
3
+
4
+ module Fluent::Plugin
5
+ class SQLQueryInput < Input
6
+ Fluent::Plugin.register_input('sqlquery_ssh', self)
7
+
8
+ helpers :thread, :storage, :compat_parameters
9
+ unless method_defined?(:log)
10
+ define_method(:log) { $log }
11
+ end
12
+
13
+ def initialize
14
+ super
15
+ require 'mysql2'
16
+ require 'net/ssh/gateway'
17
+ require 'bigdecimal'
18
+ end
19
+
20
+ config_param :host, :string, :default => '127.0.0.1'
21
+ config_param :shard_db_name, :string, :default => 'customer'
22
+ config_param :skip_shards, :array, :default => nil
23
+ config_param :shard_map_query, :string, :default => nil
24
+ config_param :ssh_key, :integer, :default => 'id_rsa'
25
+ config_param :ssh_local_port, :integer, :default => 33009
26
+ config_param :ssh_gateway, :string, :default => nil
27
+ config_param :ssh_username, :string, :default => nil, :secret => true
28
+ config_param :ssh_passphrase, :string, :default => nil, :secret => true
29
+ config_param :db_username, :string, :default => 'root', :secret => true
30
+ config_param :db_password, :string, :default => 'atqM2rQZxp9GMY', :secret => true
31
+ #config_param :database, :string, :default => nil
32
+ #config_param :databases, :array, :default => nil #databases if passed in.
33
+ config_param :db_port, :integer, :default => 33006
34
+ config_param :encoding, :string, :default => 'utf8'
35
+ config_param :interval, :time, :default => '1d'
36
+ config_param :tag, :string
37
+ config_param :cast, :hash, :default => nil #cast a hash of key (column) to cast as (type)
38
+ config_param :query, :string
39
+ config_param :nest_result, :bool, :default => false
40
+ config_param :nest_key, :string, :default => 'data'
41
+ config_param :row_count, :bool, :default => true
42
+ config_param :row_count_key, :string, :default => 'row_count'
43
+ config_param :record_hostname, :bool, :default => true
44
+
45
+ config_param :connect_timeout, :integer, :default => '30'
46
+ config_param :read_timeout, :integer, :default => '600'
47
+ config_param :init_sql, :string, :default => nil
48
+
49
+
50
+ def configure(conf)
51
+ super
52
+ end
53
+
54
+ def start
55
+ super
56
+ #thread_create(:, &method(:run))
57
+ @thread = Thread.new(&method(:run))
58
+ @ssh_port = nil
59
+ end
60
+
61
+ def shutdown
62
+ @stop = "ending run"
63
+ @ssh_gate.close(@ssh_local_port)
64
+ @mysql = nil
65
+ if @thread
66
+ @thread.join
67
+ @thread = nil
68
+ end
69
+ super
70
+ end
71
+
72
+ def run
73
+ begin
74
+ $log.info "Begin run."
75
+ loop do
76
+ break if @stop
77
+ @shard_map ||= get_shards
78
+ #loop thru shards
79
+ begin
80
+ @shard_map.each do |shard|
81
+ break if @stop
82
+ shard.each do |remotehost,db_names|
83
+ break if @stop
84
+ @mysql = nil #close connection
85
+ @ssh_gate.close(@ssh_local_port)
86
+ hostname = remotehost.gsub(/mylocaldb([0-9]*)/, "customers\\1-reporting.db.prd.us-east-silo.ls")
87
+ @host = hostname
88
+ $log.info "start shard: #{hostname}\n"
89
+ #handle the dbs
90
+ process_shard_dbs(remotehost, db_names)
91
+ $log.info "finished shard: #{hostname}\n"
92
+ end
93
+ end
94
+ @mysql = nil #close connection
95
+ @ssh_gate.close(@ssh_local_port)
96
+ #process_shards if @shard_map
97
+ $log.info "completed run all shards. sleeping for #{@interval}\n"
98
+ #end don't loop
99
+ rescue Exception => e
100
+ $log.error "error on shard: #{@current_shard}\n"
101
+ $log.error "#{e.message}\n#{e.backtrace.join("\n")}\n"
102
+ end
103
+ sleep @interval
104
+ end
105
+ rescue
106
+ # ignore
107
+ end
108
+ end
109
+ #get the shards. put the info somewhere?
110
+ def get_shards
111
+ begin
112
+ @conn ||= get_connection
113
+ @conn.select_db(@shard_db_name) #switch the customerdb
114
+ $log.info "Getting Shard Mapping: [#{@shard_map_query}]"
115
+ @conn.query("SET SESSION group_concat_max_len= 1844674407370")
116
+ shard_mapping = Array.new
117
+ shardlist = @conn.query(@shard_map_query, :cast => false, :cache_rows => false)
118
+ shardlist.each do |row|
119
+ customer_dbs = row['database_name'].split(',')
120
+ cust_host = row['database_host']
121
+ shard = Hash.new(cust_host)
122
+ shard[cust_host] = customer_dbs
123
+ shard_mapping.push(shard)
124
+ $log.info "adding #{customer_dbs}\n"
125
+ end
126
+ @conn.close
127
+ return shard_mapping
128
+ rescue Exception => e
129
+ $log.error "Can't get shard info\n"
130
+ $log.error "#{e.message}\n#{e.backtrace.join("\n")}\n"
131
+ exit!
132
+ end
133
+ end
134
+
135
+ def process_shard_dbs(remotehost, db_names)
136
+ db_names.each do |db|
137
+ $log.info "customer: #{db} shard: #{remotehost}"
138
+ return if @stop
139
+ @mysql ||= get_connection #open conn to shard.
140
+ @current_shard = remotehost
141
+ @mysql.select_db(db) #switch the customerdb
142
+ #$log.info "check customer: #{db}\n"
143
+ tag = "#{@tag}_#{db}_#{remotehost}"
144
+ record = Hash.new
145
+ result = get_exec_result
146
+ #record.store(@row_count_key, result.size) if @row_count
147
+ result.each_with_index do |data, index|
148
+ router.emit(tag, Fluent::Engine.now, record.merge(data))
149
+ end
150
+ $log.info "completed #{db} rows: #{result.size}\n"
151
+ end
152
+ end
153
+
154
+ #get the query results
155
+ def get_exec_result
156
+ result = Array.new
157
+ stmt = query(@query)
158
+ stmt.each do |row|
159
+ #to be replaced by the cast array
160
+ row['avg'] = row['avg'].to_f.round(2) if row['avg']
161
+ row['daily_total'] = row['daily_total'].to_f if row['daily_total']
162
+ row['total_transactions'] = row['total_transactions'].to_i if row['total_transactions']
163
+ row['cust_of_customer'] = row['cust_of_customer'].to_i if row['cust_of_customer']
164
+ row['shop_count'] = row['shop_count'].to_i if row['shop_count']
165
+ row['items'] = row['items'].to_i if row['items']
166
+ row['register_count'] = row['register_count'].to_i if row['register_count']
167
+ row['customer_shard'] = @current_shard if @current_shard
168
+ result.push(row)
169
+ end
170
+ return result
171
+ end
172
+
173
+ def query(query)
174
+ begin
175
+ return if @mysql.nil?
176
+ return @mysql.query(query, :cast => false, :cache_rows => false)
177
+ rescue Exception => e
178
+ $log.error "fluent-plugin-sqlquery-ssh: Query ERROR!\n"
179
+ $log.error "#{e.message}\n#{e.backtrace.join("\n")}\n"
180
+ end
181
+ end
182
+
183
+ # Returns +true+ if the column is either of type string or text.
184
+ def text?
185
+ type == :string || type == :text
186
+ end
187
+
188
+ # Returns +true+ if the column is either of type integer, float or decimal.
189
+ def number?
190
+ type == :integer || type == :float || type == :decimal
191
+ end
192
+
193
+ # convert something to a boolean
194
+ def value_to_boolean(value)
195
+ if value.is_a?(String) && value.empty?
196
+ nil
197
+ else
198
+ TRUE_VALUES.include?(value)
199
+ end
200
+ end
201
+
202
+ # Used to convert values to integer.
203
+ # handle the case when an integer column is used to store boolean values
204
+ def value_to_integer(value)
205
+ case value
206
+ when TrueClass, FalseClass
207
+ value ? 1 : 0
208
+ else
209
+ value.to_i rescue nil
210
+ end
211
+ end
212
+
213
+ # convert something to a BigDecimal
214
+ def value_to_decimal(value)
215
+ # Using .class is faster than .is_a? and
216
+ # subclasses of BigDecimal will be handled
217
+ # in the else clause
218
+ if value.class == BigDecimal
219
+ value
220
+ elsif value.respond_to?(:to_d)
221
+ value.to_d
222
+ else
223
+ value.to_s.to_d
224
+ end
225
+ end
226
+
227
+ def get_connection
228
+ begin
229
+ $log.info "Opening ssh tunnel to #{@ssh_gateway}\n"
230
+ @ssh_gate ||= Net::SSH::Gateway.new(@ssh_gateway, @ssh_username, :verbose => :debug ,:keys => [@ssh_key], :forward_agent => true)
231
+ @ssh_port = @ssh_gate.open(@host, 3306, @ssh_local_port) || @db_port
232
+ $log.info "connecting to #{@host}\n"
233
+ return Mysql2::Client.new({
234
+ :host => '127.0.0.1',
235
+ :port => @ssh_port,
236
+ :username => @db_username,
237
+ :password => @db_password,
238
+ :encoding => @encoding,
239
+ :reconnect => true,
240
+ :read_timeout => 600,
241
+ :connect_timeout => 30
242
+ })
243
+ rescue Exception => e
244
+ $log.error "fluent-plugin-sqlquery-ssh: Main Connect ERROR!\n"
245
+ $log.error "MSG: #{e.message}\n TRACE:#{e.backtrace.join("\n")} PORT: #{@ssh_port}\n"
246
+ #sleep @interval
247
+ #retry
248
+ end
249
+ return nil
250
+ end
251
+
252
+
253
+ def get_mysql_hostname
254
+ query("SHOW VARIABLES LIKE 'hostname'").each do |row|
255
+ return row.fetch('Value')
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,28 @@
1
+ require 'rubygems'
2
+ require 'bundler'
3
+ begin
4
+ Bundler.setup(:default, :development)
5
+ rescue Bundler::BundlerError => e
6
+ $stderr.puts e.message
7
+ $stderr.puts "Run `bundle install` to install missing gems"
8
+ exit e.status_code
9
+ end
10
+ require 'test/unit'
11
+
12
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
13
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
14
+ require 'fluent/test'
15
+ unless ENV.has_key?('VERBOSE')
16
+ nulllogger = Object.new
17
+ nulllogger.instance_eval {|obj|
18
+ def method_missing(method, *args)
19
+ # pass
20
+ end
21
+ }
22
+ $log = nulllogger
23
+ end
24
+
25
+ require 'fluent/plugin/in_mysql_query'
26
+
27
+ class Test::Unit::TestCase
28
+ end
@@ -0,0 +1,40 @@
1
+ require 'helper'
2
+
3
+ class SQLQueryInputTest < Test::Unit::TestCase
4
+ def setup
5
+ Fluent::Test.setup
6
+ end
7
+
8
+ CONFIG = %[
9
+ host localhost
10
+ port 3306
11
+ interval 30
12
+ tag input.mysql
13
+ query SHOW VARIABLES LIKE 'Thread_%'
14
+ record_hostname yes
15
+ ]
16
+
17
+ def create_driver(conf=CONFIG,tag='test')
18
+ Fluent::Test::OutputTestDriver.new(Fluent::MysqlQueryInput, tag).configure(conf)
19
+ end
20
+
21
+ def test_configure
22
+ assert_raise(Fluent::ConfigError) {
23
+ d = create_driver('')
24
+ }
25
+ d = create_driver %[
26
+ host localhost
27
+ port 3306
28
+ interval 30
29
+ tag input.mysql
30
+ query SHOW VARIABLES LIKE 'Thread_%'
31
+ record_hostname yes
32
+ ]
33
+ assert_equal 'localhost', d.instance.host
34
+ assert_equal 3306, d.instance.port
35
+ assert_equal 30, d.instance.interval
36
+ assert_equal 'input.mysql', d.instance.tag
37
+ assert_equal true, d.instance.record_hostname
38
+ assert_equal false, d.instance.nest_result
39
+ end
40
+ end
metadata ADDED
@@ -0,0 +1,149 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fluent-plugin-sqlquery-ssh
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.10
5
+ platform: ruby
6
+ authors:
7
+ - Niall Brown
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-11-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ~>
18
+ - !ruby/object:Gem::Version
19
+ version: '10.0'
20
+ - - '>='
21
+ - !ruby/object:Gem::Version
22
+ version: 10.0.4
23
+ type: :development
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ version: '10.0'
30
+ - - '>='
31
+ - !ruby/object:Gem::Version
32
+ version: 10.0.4
33
+ - !ruby/object:Gem::Dependency
34
+ name: test-unit
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ~>
38
+ - !ruby/object:Gem::Version
39
+ version: '3.1'
40
+ - - '>='
41
+ - !ruby/object:Gem::Version
42
+ version: 3.1.0
43
+ type: :development
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ~>
48
+ - !ruby/object:Gem::Version
49
+ version: '3.1'
50
+ - - '>='
51
+ - !ruby/object:Gem::Version
52
+ version: 3.1.0
53
+ - !ruby/object:Gem::Dependency
54
+ name: fluentd
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - '>='
58
+ - !ruby/object:Gem::Version
59
+ version: 0.10.30
60
+ - - <
61
+ - !ruby/object:Gem::Version
62
+ version: '2'
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - '>='
68
+ - !ruby/object:Gem::Version
69
+ version: 0.10.30
70
+ - - <
71
+ - !ruby/object:Gem::Version
72
+ version: '2'
73
+ - !ruby/object:Gem::Dependency
74
+ name: mysql2
75
+ requirement: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ~>
78
+ - !ruby/object:Gem::Version
79
+ version: 0.3.11
80
+ type: :runtime
81
+ prerelease: false
82
+ version_requirements: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ~>
85
+ - !ruby/object:Gem::Version
86
+ version: 0.3.11
87
+ - !ruby/object:Gem::Dependency
88
+ name: net-ssh-gateway
89
+ requirement: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ~>
92
+ - !ruby/object:Gem::Version
93
+ version: '1.2'
94
+ - - '>='
95
+ - !ruby/object:Gem::Version
96
+ version: 1.2.0
97
+ type: :runtime
98
+ prerelease: false
99
+ version_requirements: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ~>
102
+ - !ruby/object:Gem::Version
103
+ version: '1.2'
104
+ - - '>='
105
+ - !ruby/object:Gem::Version
106
+ version: 1.2.0
107
+ description:
108
+ email: niall.brown@lightspeedretail.com
109
+ executables: []
110
+ extensions: []
111
+ extra_rdoc_files: []
112
+ files:
113
+ - .gitignore
114
+ - Gemfile
115
+ - LICENSE.txt
116
+ - README.md
117
+ - Rakefile
118
+ - fluent-plugin-sqlquery-ssh.gemspec
119
+ - lib/fluent/plugin/in_sqlquery_ssh.rb
120
+ - test/helper.rb
121
+ - test/plugin/test_in_sqlquery_ssh.rb
122
+ homepage:
123
+ licenses:
124
+ - Apache-2.0
125
+ metadata: {}
126
+ post_install_message:
127
+ rdoc_options: []
128
+ require_paths:
129
+ - lib
130
+ required_ruby_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - '>='
133
+ - !ruby/object:Gem::Version
134
+ version: '0'
135
+ required_rubygems_version: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - '>='
138
+ - !ruby/object:Gem::Version
139
+ version: '0'
140
+ requirements: []
141
+ rubyforge_project:
142
+ rubygems_version: 2.0.14.1
143
+ signing_key:
144
+ specification_version: 4
145
+ summary: Fluentd Input plugin to execute mysql query and fetch rows. It is useful
146
+ for stationary interval metrics measurement.
147
+ test_files:
148
+ - test/helper.rb
149
+ - test/plugin/test_in_sqlquery_ssh.rb