hazetug 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- 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