hazetug 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/Gemfile +5 -0
- data/LICENSE.txt +22 -0
- data/README.md +188 -0
- data/Rakefile +1 -0
- data/hazetug.gemspec +28 -0
- data/lib/hazetug/cli/action.rb +30 -0
- data/lib/hazetug/cli/bootstrap.rb +88 -0
- data/lib/hazetug/cli.rb +74 -0
- data/lib/hazetug/compute.rb +80 -0
- data/lib/hazetug/config.rb +26 -0
- data/lib/hazetug/haze/cloud_server.rb +71 -0
- data/lib/hazetug/haze/digital_ocean.rb +71 -0
- data/lib/hazetug/haze/linode.rb +66 -0
- data/lib/hazetug/haze.rb +81 -0
- data/lib/hazetug/hazetug.rb +31 -0
- data/lib/hazetug/task.rb +53 -0
- data/lib/hazetug/tug/knife.rb +97 -0
- data/lib/hazetug/tug.rb +85 -0
- data/lib/hazetug/ui.rb +75 -0
- data/lib/hazetug/version.rb +3 -0
- data/lib/hazetug.rb +4 -0
- metadata +164 -0
@@ -0,0 +1,71 @@
|
|
1
|
+
require 'hazetug/haze/cloud_server'
|
2
|
+
|
3
|
+
class Hazetug
|
4
|
+
class Haze
|
5
|
+
class DigitalOcean < Haze
|
6
|
+
include CloudServer
|
7
|
+
|
8
|
+
requires :name, :location, :flavor, :image
|
9
|
+
defaults :backups_active => true, :private_networking => true
|
10
|
+
|
11
|
+
def initialize(config={})
|
12
|
+
super
|
13
|
+
@config[:bits] = bits_from_string(@config[:image])
|
14
|
+
@config[:image_slug] = image_from_string(@config[:image])
|
15
|
+
end
|
16
|
+
|
17
|
+
def create_server_args
|
18
|
+
{
|
19
|
+
:name => config[:name],
|
20
|
+
:region_id => lookup(:location).id,
|
21
|
+
:flavor_id => lookup(:flavor).id,
|
22
|
+
:image_id => lookup(:image).id,
|
23
|
+
:ssh_key_ids => ssh_key_ids,
|
24
|
+
:backups_active => config[:backups_active],
|
25
|
+
:private_networking => config[:private_networking]
|
26
|
+
}
|
27
|
+
end
|
28
|
+
|
29
|
+
def compare_location?(o)
|
30
|
+
a = o.attributes
|
31
|
+
value = config[:location]
|
32
|
+
a[:name].match(/^#{value}/i) != nil ||
|
33
|
+
a['slug'] == config[:location] || a['slug'].match(/^#{value}/) != nil
|
34
|
+
end
|
35
|
+
|
36
|
+
def compare_flavor?(o)
|
37
|
+
a = o.attributes
|
38
|
+
value = config[:flavor]
|
39
|
+
a[:memory] == memory_in_megabytes(value) ||
|
40
|
+
a[:name].match(/^#{value}/i)
|
41
|
+
end
|
42
|
+
|
43
|
+
def compare_image?(o)
|
44
|
+
a = o.attributes
|
45
|
+
img_id = image_from_string(a[:name])
|
46
|
+
bits = bits_from_string(a[:name])
|
47
|
+
img_id.match(/^#{config[:image_slug]}/) && bits == config[:bits]
|
48
|
+
end
|
49
|
+
|
50
|
+
def public_ip_address
|
51
|
+
server and server.public_ip_address
|
52
|
+
end
|
53
|
+
|
54
|
+
def private_ip_address
|
55
|
+
server and server.private_ip_address
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def ssh_key_ids
|
61
|
+
if config[:ssh_keys].nil? || config[:ssh_keys].empty?
|
62
|
+
compute.ssh_keys.map(&:id)
|
63
|
+
else
|
64
|
+
config[:ssh_keys].map do |n|
|
65
|
+
k = lookup(:ssh_key, n) and k.id
|
66
|
+
end.compact
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
require 'hazetug/haze/cloud_server'
|
3
|
+
|
4
|
+
class Hazetug
|
5
|
+
class Haze
|
6
|
+
class Linode < Haze
|
7
|
+
include CloudServer
|
8
|
+
|
9
|
+
requires :name, :location, :flavor, :image
|
10
|
+
defaults :payment_terms => 1
|
11
|
+
|
12
|
+
def initialize(config={})
|
13
|
+
super
|
14
|
+
@config[:bits] = bits_from_string(@config[:image])
|
15
|
+
@config[:image_slug] = image_from_string(@config[:image])
|
16
|
+
@config[:ssh_password] ||= SecureRandom.hex
|
17
|
+
end
|
18
|
+
|
19
|
+
def create_server_args
|
20
|
+
latest = /^Latest #{config[:bits]} bit/
|
21
|
+
{
|
22
|
+
:name => config[:name],
|
23
|
+
:data_center => lookup(:location),
|
24
|
+
:flavor => lookup(:flavor),
|
25
|
+
:image => lookup(:image),
|
26
|
+
:kernel => compute.kernels.find {|k| k.name.match(latest)},
|
27
|
+
:password => config[:ssh_password],
|
28
|
+
:payment_terms => config[:payment_terms]
|
29
|
+
}
|
30
|
+
end
|
31
|
+
|
32
|
+
def compare_location?(o)
|
33
|
+
a = o.attributes
|
34
|
+
value = config[:location]
|
35
|
+
a[:location].match(/^#{value}/i) != nil ||
|
36
|
+
a[:abbr] == config[:location] || a[:abbr].match(/^#{value}/) != nil
|
37
|
+
end
|
38
|
+
|
39
|
+
def compare_flavor?(o)
|
40
|
+
a = o.attributes
|
41
|
+
value = config[:flavor]
|
42
|
+
a[:ram] == memory_in_megabytes(value) ||
|
43
|
+
a[:name].match(/^#{value}/i)
|
44
|
+
end
|
45
|
+
|
46
|
+
def compare_image?(o)
|
47
|
+
a = o.attributes
|
48
|
+
img_id = image_from_string(a[:name])
|
49
|
+
bits = a[:bits]
|
50
|
+
img_id.match(/^#{config[:image_slug]}/) && bits == config[:bits]
|
51
|
+
end
|
52
|
+
|
53
|
+
def public_ip_address
|
54
|
+
server and server.public_ip_address
|
55
|
+
end
|
56
|
+
|
57
|
+
def private_ip_address
|
58
|
+
server and begin
|
59
|
+
found = server.ips.find {|ip| not ip.public}
|
60
|
+
found and found.ip
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
data/lib/hazetug/haze.rb
ADDED
@@ -0,0 +1,81 @@
|
|
1
|
+
require 'hazetug/config'
|
2
|
+
require 'hazetug/compute'
|
3
|
+
require 'hazetug/ui'
|
4
|
+
require 'hazetug/tug'
|
5
|
+
require 'chef/mash'
|
6
|
+
|
7
|
+
class Hazetug
|
8
|
+
class Haze
|
9
|
+
include Hazetug::UI::Mixin
|
10
|
+
|
11
|
+
RE_BITS = /-?x(32)$|-?x(64)$|(32)bit|(64)bit/i
|
12
|
+
attr_reader :config, :compute, :compute_name, :server
|
13
|
+
|
14
|
+
def initialize(config={})
|
15
|
+
@compute_name = Hazetug.leaf_klass_name(self.class)
|
16
|
+
@compute = Hazetug::Compute.const_get(compute_name).new
|
17
|
+
@config = configure(config)
|
18
|
+
@server = nil
|
19
|
+
@sshable = false
|
20
|
+
end
|
21
|
+
|
22
|
+
def provision
|
23
|
+
provision_server
|
24
|
+
wait_for_ssh
|
25
|
+
rescue Fog::Errors::Error
|
26
|
+
ui.error "[#{compute_name}] #{$!.inspect}"
|
27
|
+
ui.msg $@
|
28
|
+
exit(1)
|
29
|
+
end
|
30
|
+
|
31
|
+
def configure(config)
|
32
|
+
input = config.keys.map(&:to_sym)
|
33
|
+
requires = self.class.requires
|
34
|
+
unless (norequired = requires.select {|r| not input.include?(r)}).empty?
|
35
|
+
ui.error "Required options missing: #{norequired.join(', ')}"
|
36
|
+
raise ArgumentError, "Haze options missing"
|
37
|
+
end
|
38
|
+
Mash.new(self.class.defaults.merge(config))
|
39
|
+
end
|
40
|
+
|
41
|
+
class << self
|
42
|
+
def requires(*args)
|
43
|
+
if args.empty?
|
44
|
+
@requires
|
45
|
+
else
|
46
|
+
@requires = args.flatten.dup
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def defaults(hash=nil)
|
51
|
+
if hash.nil?
|
52
|
+
@defaults
|
53
|
+
else
|
54
|
+
@defaults = hash
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def [](haze_name)
|
59
|
+
klass = Hazetug.camel_case_name(haze_name)
|
60
|
+
Hazetug::Haze.const_get(klass)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def public_ip_address
|
65
|
+
end
|
66
|
+
|
67
|
+
def private_ip_address
|
68
|
+
end
|
69
|
+
|
70
|
+
protected
|
71
|
+
|
72
|
+
def provision_server
|
73
|
+
ui.error "#{compute_name} Provisioning is not impemented"
|
74
|
+
end
|
75
|
+
|
76
|
+
def wait_for_ssh
|
77
|
+
ui.error "#{compute_name} Waiting for shh is not impemented"
|
78
|
+
end
|
79
|
+
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'hazetug/version'
|
2
|
+
require 'hazetug/compute'
|
3
|
+
require 'hazetug/haze'
|
4
|
+
require 'hazetug/haze/linode'
|
5
|
+
require 'hazetug/haze/digital_ocean'
|
6
|
+
|
7
|
+
class Hazetug
|
8
|
+
class Exception < ::Exception
|
9
|
+
end
|
10
|
+
|
11
|
+
class << self
|
12
|
+
def camel_case_name(string_or_symbol)
|
13
|
+
string_or_symbol.to_s.split('_').map(&:capitalize).join
|
14
|
+
end
|
15
|
+
|
16
|
+
def leaf_klass_name(klass)
|
17
|
+
if klass.is_a? String
|
18
|
+
klass.split('::').last
|
19
|
+
else
|
20
|
+
klass.name.split('::').last
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def ssh_keys(compute_name=nil)
|
25
|
+
ssh_attr = []
|
26
|
+
ssh_attr << "#{compute_name.downcase}_ssh_keys" if compute_name
|
27
|
+
ssh_attr << "ssh_keys"
|
28
|
+
Hazetug::Config[ssh_attr.find {|a| Hazetug::Config[a]}]
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
data/lib/hazetug/task.rb
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
require 'psych'
|
2
|
+
require 'chef/mash'
|
3
|
+
require 'chef/mixin/deep_merge'
|
4
|
+
require 'hazetug/ui'
|
5
|
+
|
6
|
+
class Hazetug
|
7
|
+
class Task
|
8
|
+
include Hazetug::UI::Mixin
|
9
|
+
|
10
|
+
def initialize(path)
|
11
|
+
path = File.expand_path(path)
|
12
|
+
@task = Mash.new(Psych.load_file(path))
|
13
|
+
rescue Psych::Exception
|
14
|
+
ui.fatal "Unable to parse hazetug task file: '#{path}'"
|
15
|
+
puts $!.inspect, $@
|
16
|
+
exit(1)
|
17
|
+
rescue SystemCallError
|
18
|
+
ui.fatal "Unable to read file: '#{path}'"
|
19
|
+
exit(1)
|
20
|
+
end
|
21
|
+
|
22
|
+
def [](key)
|
23
|
+
@task[key]
|
24
|
+
end
|
25
|
+
|
26
|
+
def hosts_to_bootstrap(env={}, &block)
|
27
|
+
return if block.nil?
|
28
|
+
base_conf = Mash.new(task)
|
29
|
+
hosts = base_conf.delete(:bootstrap)
|
30
|
+
base_conf = Chef::Mixin::DeepMerge.merge(base_conf, env)
|
31
|
+
hosts.each do |conf|
|
32
|
+
merged = Chef::Mixin::DeepMerge.merge(base_conf, conf)
|
33
|
+
block.call(merged)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
class << self
|
38
|
+
protected :new
|
39
|
+
|
40
|
+
def load_from_file(path)
|
41
|
+
@instance ||= self.new(path)
|
42
|
+
end
|
43
|
+
|
44
|
+
def [](key)
|
45
|
+
@instance and @instance[key]
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
protected
|
50
|
+
attr_reader :task
|
51
|
+
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
require 'chef/knife/ssh'
|
2
|
+
require 'chef/knife/bootstrap'
|
3
|
+
require 'hazetug/tug'
|
4
|
+
|
5
|
+
# Monkey Patch:)
|
6
|
+
# Extend knife bootstrap context with our data
|
7
|
+
class Chef::Knife::Core::BootstrapContext
|
8
|
+
def hazetug; @config[:hazetug]; end
|
9
|
+
end
|
10
|
+
|
11
|
+
class Chef::Knife::Ssh < Chef::Knife
|
12
|
+
def run
|
13
|
+
extend Chef::Mixin::Command
|
14
|
+
@longest = 0
|
15
|
+
configure_attribute
|
16
|
+
configure_user
|
17
|
+
configure_password
|
18
|
+
configure_identity_file
|
19
|
+
configure_gateway
|
20
|
+
configure_session
|
21
|
+
exit_status = ssh_command(@name_args[1..-1].join(" "))
|
22
|
+
session.close
|
23
|
+
|
24
|
+
exit_status
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
class Hazetug
|
29
|
+
class Tug
|
30
|
+
class Knife < Tug
|
31
|
+
|
32
|
+
def bootstrap_server
|
33
|
+
[
|
34
|
+
:template_file,
|
35
|
+
:identity_file,
|
36
|
+
:ssh_user,
|
37
|
+
:ssh_password,
|
38
|
+
:host_key_verify
|
39
|
+
].each do |opt|
|
40
|
+
kb.config[opt] = bootstrap_options[opt]
|
41
|
+
end
|
42
|
+
[
|
43
|
+
:environment,
|
44
|
+
:chef_server_url,
|
45
|
+
:validation_key
|
46
|
+
].each do |opt|
|
47
|
+
Chef::Config[opt] = bootstrap_options[opt]
|
48
|
+
end
|
49
|
+
kb.name_args = [haze.server.ssh_ip_address]
|
50
|
+
kb.run
|
51
|
+
ensure
|
52
|
+
@kb and @kb.ui.stdout.close
|
53
|
+
end
|
54
|
+
|
55
|
+
def kb
|
56
|
+
@kb ||= begin
|
57
|
+
lf = create_log_file
|
58
|
+
Chef::Knife::Bootstrap.load_deps
|
59
|
+
kb = Chef::Knife::Bootstrap.new
|
60
|
+
kb.config[:hazetug] = config
|
61
|
+
kb.ui = Chef::Knife::UI.new(lf, lf, lf, {verbosity: 2})
|
62
|
+
kb
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def bootstrap_options
|
67
|
+
@bootstrap_options ||= begin
|
68
|
+
template = options[:opts][:bootstrap]
|
69
|
+
opts = {}
|
70
|
+
opts[:validation_key] = File.expand_path(config[:chef_validation_key] || 'validation.pem')
|
71
|
+
opts[:template_file] = File.expand_path(template || 'bootstrap.erb')
|
72
|
+
opts[:ssh_user] = config[:ssh_user] || 'root'
|
73
|
+
opts[:ssh_password] = config[:ssh_password]
|
74
|
+
opts[:environment] = config[:chef_environment]
|
75
|
+
opts[:host_key_verify] = config[:host_key_verify] || false
|
76
|
+
opts[:chef_server_url] = config[:chef_server_url]
|
77
|
+
opts[:identity_file] = preferred_ssh_identity if not opts[:ssh_password]
|
78
|
+
opts
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def preferred_ssh_identity
|
83
|
+
@preferred_ssh_identity ||= begin
|
84
|
+
compute = Hazetug.leaf_klass_name(haze.class.name).downcase
|
85
|
+
identity = config[:identity_file]
|
86
|
+
key_path = (Hazetug::Config["#{compute}_ssh_keys"] || []).first
|
87
|
+
if identity.nil? && key_path.nil?
|
88
|
+
raise Hazetug::Exception, "identity file not specified, use #{compute}_ssh_keys or identity_file"
|
89
|
+
end
|
90
|
+
identity ||= File.expand_path(key_path)
|
91
|
+
identity
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
data/lib/hazetug/tug.rb
ADDED
@@ -0,0 +1,85 @@
|
|
1
|
+
require 'hazetug/tug/knife'
|
2
|
+
require 'hazetug/ui'
|
3
|
+
require 'chef/mash'
|
4
|
+
|
5
|
+
class Hazetug
|
6
|
+
class Tug
|
7
|
+
include Hazetug::UI::Mixin
|
8
|
+
|
9
|
+
SSH_OPTIONS = [
|
10
|
+
:ssh_user, :ssh_password, :ssh_port,
|
11
|
+
:ssh_host_key_verify, :ssh_keys
|
12
|
+
]
|
13
|
+
LOGDIR = "#{Dir.pwd}/logs"
|
14
|
+
|
15
|
+
attr_reader :haze, :config, :options
|
16
|
+
|
17
|
+
def initialize(config={}, haze=nil)
|
18
|
+
@haze = haze
|
19
|
+
@config = config
|
20
|
+
end
|
21
|
+
|
22
|
+
def tug_name
|
23
|
+
@tug_name ||= self.class.name.split('::').last
|
24
|
+
end
|
25
|
+
|
26
|
+
def bootstrap(options={})
|
27
|
+
if haze && haze.server && haze.server.sshable?
|
28
|
+
@options = options
|
29
|
+
haztug_set_variables
|
30
|
+
ip = config[:public_ip_address]
|
31
|
+
ui.msg "[#{tug_name}] bootstraping server #{haze.config[:name]}, ip: #{ip}"
|
32
|
+
exit_status = bootstrap_server
|
33
|
+
if exit_status.is_a?(Fixnum) && exit_status != 0
|
34
|
+
ui.error "[#{tug_name}] bootstraping server #{haze.config[:name]} failed."
|
35
|
+
else
|
36
|
+
ui.msg "[#{tug_name}] bootstraping server #{haze.config[:name]} done."
|
37
|
+
end
|
38
|
+
else
|
39
|
+
ui.error "#{haze.compute_name} skipping bootstrap, server #{haze.config[:name]} not ready"
|
40
|
+
end
|
41
|
+
rescue Hazetug::Exception => e
|
42
|
+
ui.error "[#{haze.compute_name}] #{e.message}"
|
43
|
+
end
|
44
|
+
|
45
|
+
class << self
|
46
|
+
def [](symbol_or_string)
|
47
|
+
klass = Hazetug.camel_case_name(symbol_or_string)
|
48
|
+
const_get(klass)
|
49
|
+
end
|
50
|
+
|
51
|
+
def ssh_options_from(config)
|
52
|
+
SSH_OPTIONS.inject({}) do |hash, k|
|
53
|
+
if value = (config[k])
|
54
|
+
hash[k] = value
|
55
|
+
end
|
56
|
+
hash
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def haztug_set_variables
|
64
|
+
{
|
65
|
+
compute_name: haze.compute_name.downcase,
|
66
|
+
public_ip_address: (haze.public_ip_address || haze.server.ssh_ip_address rescue nil),
|
67
|
+
private_ip_address: haze.private_ip_address
|
68
|
+
}.each do |key, value|
|
69
|
+
config[key] = value if value
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
protected
|
74
|
+
|
75
|
+
def create_log_file
|
76
|
+
unless File.directory?(LOGDIR)
|
77
|
+
Dir.mkdir(LOGDIR)
|
78
|
+
end
|
79
|
+
log = File.new("#{LOGDIR}/#{haze.config[:name]}", "w+")
|
80
|
+
log.sync = true
|
81
|
+
log
|
82
|
+
end
|
83
|
+
|
84
|
+
end
|
85
|
+
end
|
data/lib/hazetug/ui.rb
ADDED
@@ -0,0 +1,75 @@
|
|
1
|
+
class Hazetug
|
2
|
+
class UI
|
3
|
+
module Mixin
|
4
|
+
def self.included(includer)
|
5
|
+
includer.class_exec do
|
6
|
+
define_method(:ui) {Hazetug::UI.instance}
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_reader :stdout
|
12
|
+
attr_reader :stderr
|
13
|
+
attr_reader :stdin
|
14
|
+
|
15
|
+
def initialize(stdout, stderr, stdin)
|
16
|
+
@stdout, @stderr, @stdin = stdout, stderr, stdin
|
17
|
+
end
|
18
|
+
|
19
|
+
def highline
|
20
|
+
@highline ||= begin
|
21
|
+
require 'highline'
|
22
|
+
HighLine.new
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def msg(message)
|
27
|
+
begin
|
28
|
+
stdout.puts message
|
29
|
+
rescue Errno::EPIPE => e
|
30
|
+
raise e
|
31
|
+
exit 0
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
alias :info :msg
|
36
|
+
|
37
|
+
def err(message)
|
38
|
+
begin
|
39
|
+
stderr.puts message
|
40
|
+
rescue Errno::EPIPE => e
|
41
|
+
raise e
|
42
|
+
exit 0
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def warn(message)
|
47
|
+
err("#{color('WARNING:', :yellow, :bold)} #{message}")
|
48
|
+
end
|
49
|
+
|
50
|
+
def error(message)
|
51
|
+
err("#{color('ERROR:', :red, :bold)} #{message}")
|
52
|
+
end
|
53
|
+
|
54
|
+
def fatal(message)
|
55
|
+
err("#{color('FATAL:', :red, :bold)} #{message}")
|
56
|
+
end
|
57
|
+
|
58
|
+
def color(string, *colors)
|
59
|
+
if color?
|
60
|
+
highline.color(string, *colors)
|
61
|
+
else
|
62
|
+
string
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def color?
|
67
|
+
stdout.tty?
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.instance
|
71
|
+
@instance ||= Hazetug::UI.new(STDOUT, STDERR, STDIN)
|
72
|
+
end
|
73
|
+
|
74
|
+
end
|
75
|
+
end
|
data/lib/hazetug.rb
ADDED