kytoon 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +29 -0
- data/LICENSE.txt +20 -0
- data/README.rdoc +81 -0
- data/Rakefile +35 -0
- data/VERSION +1 -0
- data/config/server_group_vpc.json +14 -0
- data/config/server_group_xen.json +24 -0
- data/lib/kytoon.rb +8 -0
- data/lib/kytoon/providers/cloud_servers_vpc.rb +6 -0
- data/lib/kytoon/providers/cloud_servers_vpc/client.rb +197 -0
- data/lib/kytoon/providers/cloud_servers_vpc/connection.rb +148 -0
- data/lib/kytoon/providers/cloud_servers_vpc/server.rb +121 -0
- data/lib/kytoon/providers/cloud_servers_vpc/server_group.rb +401 -0
- data/lib/kytoon/providers/cloud_servers_vpc/ssh_public_key.rb +29 -0
- data/lib/kytoon/providers/cloud_servers_vpc/vpn_network_interface.rb +33 -0
- data/lib/kytoon/providers/xenserver.rb +1 -0
- data/lib/kytoon/providers/xenserver/server_group.rb +371 -0
- data/lib/kytoon/server_group.rb +46 -0
- data/lib/kytoon/ssh_util.rb +23 -0
- data/lib/kytoon/util.rb +118 -0
- data/lib/kytoon/version.rb +8 -0
- data/lib/kytoon/vpn/vpn_connection.rb +46 -0
- data/lib/kytoon/vpn/vpn_network_manager.rb +237 -0
- data/lib/kytoon/vpn/vpn_openvpn.rb +112 -0
- data/lib/kytoon/xml_util.rb +15 -0
- data/rake/kytoon.rake +115 -0
- data/test/client_test.rb +111 -0
- data/test/helper.rb +18 -0
- data/test/server_group_test.rb +253 -0
- data/test/server_test.rb +69 -0
- data/test/ssh_util_test.rb +22 -0
- data/test/test_helper.rb +194 -0
- data/test/test_kytoon.rb +7 -0
- data/test/util_test.rb +23 -0
- data/test/vpn_network_manager_test.rb +40 -0
- metadata +247 -0
data/rake/kytoon.rake
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
include Kytoon
|
2
|
+
|
3
|
+
namespace :group do
|
4
|
+
TMP_SG=File.join(KYTOON_PROJECT, 'tmp', 'server_groups')
|
5
|
+
|
6
|
+
directory TMP_SG
|
7
|
+
|
8
|
+
task :init => [TMP_SG] do
|
9
|
+
ServerGroup.init
|
10
|
+
end
|
11
|
+
|
12
|
+
desc "Create a new group of cloud servers"
|
13
|
+
task :create => [ "init" ] do
|
14
|
+
sg = ServerGroup.create
|
15
|
+
puts "Server group ID #{sg.id} created."
|
16
|
+
end
|
17
|
+
|
18
|
+
desc "List existing cloud server groups."
|
19
|
+
task :list => "init" do
|
20
|
+
|
21
|
+
server_groups=nil
|
22
|
+
server_groups=ServerGroup.index(:source => "cache")
|
23
|
+
if server_groups.size > 0
|
24
|
+
puts "Server groups:"
|
25
|
+
server_groups.sort { |a,b| b.id <=> a.id }.each do |sg|
|
26
|
+
gw=sg.gateway_ip.nil? ? "" : " (#{sg.gateway_ip})"
|
27
|
+
puts "\t :id => #{sg.id}, :name => #{sg.name} #{gw}"
|
28
|
+
end
|
29
|
+
else
|
30
|
+
puts "No server groups."
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
|
35
|
+
desc "Print information for a cloud server group"
|
36
|
+
task :show => [ "init" ] do
|
37
|
+
sg = ServerGroup.get
|
38
|
+
sg.pretty_print
|
39
|
+
end
|
40
|
+
|
41
|
+
desc "Delete a cloud server group"
|
42
|
+
task :delete => ["init"] do
|
43
|
+
|
44
|
+
sg = ServerGroup.get
|
45
|
+
puts "Deleting cloud server group ID: #{sg.id}."
|
46
|
+
sg.delete
|
47
|
+
SshUtil.remove_known_hosts_ip(sg.gateway_ip)
|
48
|
+
|
49
|
+
end
|
50
|
+
|
51
|
+
desc "Print the VPN gateway IP address"
|
52
|
+
task :gateway_ip do
|
53
|
+
group = ServerGroup.get
|
54
|
+
puts group.gateway_ip
|
55
|
+
end
|
56
|
+
|
57
|
+
end
|
58
|
+
|
59
|
+
desc "SSH into the most recently created VPN gateway server."
|
60
|
+
task :ssh => 'group:init' do
|
61
|
+
|
62
|
+
sg=ServerGroup.get
|
63
|
+
args=ARGV[1, ARGV.length].join(" ")
|
64
|
+
if (ARGV[1] and ARGV[1] =~ /^GROUP_.*/) and (ARGV[2] and ARGV[2] =~ /^GROUP_.*/)
|
65
|
+
args=ARGV[3, ARGV.length].join(" ")
|
66
|
+
elsif ARGV[1] and ARGV[1] =~ /^GROUP_.*/
|
67
|
+
args=ARGV[2, ARGV.length].join(" ")
|
68
|
+
end
|
69
|
+
exec("ssh -o \"StrictHostKeyChecking no\" root@#{sg.gateway_ip} #{args}")
|
70
|
+
end
|
71
|
+
|
72
|
+
desc "Print help and usage information"
|
73
|
+
task :usage do
|
74
|
+
|
75
|
+
puts ""
|
76
|
+
puts "Kytoon Toolkit Version: #{Kytoon::Version::VERSION}"
|
77
|
+
puts ""
|
78
|
+
puts "The following tasks are available:"
|
79
|
+
|
80
|
+
puts %x{cd #{KYTOON_PROJECT} && rake -T}
|
81
|
+
puts "----"
|
82
|
+
puts "Example commands:"
|
83
|
+
puts ""
|
84
|
+
puts "\t- Create a new server group."
|
85
|
+
puts ""
|
86
|
+
puts "\t\t$ rake group:create"
|
87
|
+
|
88
|
+
puts ""
|
89
|
+
puts "\t- List your currently running server groups."
|
90
|
+
puts ""
|
91
|
+
puts "\t\t$ rake group:list"
|
92
|
+
|
93
|
+
puts ""
|
94
|
+
puts "\t- List all remote groups using a common Cloud Servers VPC account."
|
95
|
+
puts ""
|
96
|
+
puts "\t\t$ rake group:list"
|
97
|
+
|
98
|
+
puts ""
|
99
|
+
puts "\t- SSH into the current (most recently created) server group."
|
100
|
+
puts ""
|
101
|
+
puts "\t\t$ rake ssh"
|
102
|
+
|
103
|
+
puts ""
|
104
|
+
puts "\t- SSH into a server group with an ID of 3."
|
105
|
+
puts ""
|
106
|
+
puts "\t\t$ rake ssh GROUP_ID=3"
|
107
|
+
|
108
|
+
puts ""
|
109
|
+
puts "\t- Delete the server group with an ID of 3."
|
110
|
+
puts ""
|
111
|
+
puts "\t\t$ rake group:delete GROUP_ID=3"
|
112
|
+
|
113
|
+
end
|
114
|
+
|
115
|
+
task :default => 'usage'
|
data/test/client_test.rb
ADDED
@@ -0,0 +1,111 @@
|
|
1
|
+
$:.unshift File.dirname(__FILE__)
|
2
|
+
require 'test_helper'
|
3
|
+
|
4
|
+
module Kytoon
|
5
|
+
module Providers
|
6
|
+
module CloudServersVPC
|
7
|
+
|
8
|
+
class ClientTest < Test::Unit::TestCase
|
9
|
+
|
10
|
+
include Kytoon::Providers::CloudServersVPC
|
11
|
+
|
12
|
+
def setup
|
13
|
+
@tmp_dir=TmpDir.new_tmp_dir
|
14
|
+
Client.data_dir=@tmp_dir
|
15
|
+
end
|
16
|
+
|
17
|
+
def teardown
|
18
|
+
FileUtils.rm_rf(@tmp_dir)
|
19
|
+
end
|
20
|
+
|
21
|
+
def test_new
|
22
|
+
client=Client.new(:name => "test", :description => "zz", :status => "Pending")
|
23
|
+
assert_equal "test", client.name
|
24
|
+
assert_equal "zz", client.description
|
25
|
+
assert_equal 0, client.vpn_network_interfaces.size
|
26
|
+
end
|
27
|
+
|
28
|
+
def test_from_xml
|
29
|
+
client=Client.from_xml(CLIENT_XML)
|
30
|
+
assert_equal "local", client.name
|
31
|
+
assert_equal "Toolkit Client: local", client.description
|
32
|
+
assert_equal 5, client.id
|
33
|
+
assert_equal 11, client.server_group_id
|
34
|
+
vni=client.vpn_network_interfaces[0]
|
35
|
+
assert_not_nil vni.client_key
|
36
|
+
assert_not_nil vni.client_cert
|
37
|
+
assert_not_nil vni.ca_cert
|
38
|
+
end
|
39
|
+
|
40
|
+
def test_client_to_and_from_xml
|
41
|
+
client=Client.from_xml(CLIENT_XML)
|
42
|
+
xml=client.to_xml
|
43
|
+
assert_not_nil xml
|
44
|
+
client=Client.from_xml(xml)
|
45
|
+
assert_equal "local", client.name
|
46
|
+
assert_equal "Toolkit Client: local", client.description
|
47
|
+
assert_equal 5, client.id
|
48
|
+
assert_equal 11, client.server_group_id
|
49
|
+
vni=client.vpn_network_interfaces[0]
|
50
|
+
assert_not_nil vni.client_key
|
51
|
+
assert_not_nil vni.client_cert
|
52
|
+
assert_not_nil vni.ca_cert
|
53
|
+
end
|
54
|
+
|
55
|
+
def test_get
|
56
|
+
|
57
|
+
tmp_dir=TmpDir.new_tmp_dir
|
58
|
+
File.open("#{tmp_dir}/5.xml", 'w') do |f|
|
59
|
+
f.write(CLIENT_XML)
|
60
|
+
end
|
61
|
+
Client.data_dir=tmp_dir
|
62
|
+
|
63
|
+
Connection.stubs(:get).returns(CLIENT_XML)
|
64
|
+
|
65
|
+
# should raise exception if no ID is set and doing a remote lookup
|
66
|
+
assert_raises(RuntimeError) do
|
67
|
+
Client.get
|
68
|
+
end
|
69
|
+
|
70
|
+
client=Client.get(:id => "1234")
|
71
|
+
assert_not_nil client
|
72
|
+
assert_equal "Toolkit Client: local", client.description
|
73
|
+
|
74
|
+
client=Client.get(:id => "5", :source => "cache")
|
75
|
+
assert_not_nil client
|
76
|
+
assert_equal "Toolkit Client: local", client.description
|
77
|
+
|
78
|
+
#nonexistent group from cache
|
79
|
+
ENV['GROUP_ID']="1234"
|
80
|
+
assert_raises(RuntimeError) do
|
81
|
+
Client.get(:source => "cache")
|
82
|
+
end
|
83
|
+
|
84
|
+
#invalid get source
|
85
|
+
assert_raises(RuntimeError) do
|
86
|
+
Client.get(:id => "5", :source => "asdf")
|
87
|
+
end
|
88
|
+
|
89
|
+
end
|
90
|
+
|
91
|
+
def test_delete
|
92
|
+
|
93
|
+
client=Client.from_xml(CLIENT_XML)
|
94
|
+
client.delete
|
95
|
+
assert_equal false, File.exists?(File.join(Client.data_dir, "#{client.id}.xml"))
|
96
|
+
|
97
|
+
end
|
98
|
+
|
99
|
+
def test_create
|
100
|
+
|
101
|
+
Connection.stubs(:post).returns(CLIENT_XML)
|
102
|
+
client=Client.create(ServerGroup.from_xml(SERVER_GROUP_XML), "local")
|
103
|
+
assert_equal "local", client.name
|
104
|
+
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|
108
|
+
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
data/test/helper.rb
ADDED
@@ -0,0 +1,18 @@
|
|
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
|
+
require 'shoulda'
|
12
|
+
|
13
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
14
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
15
|
+
require 'kytoon'
|
16
|
+
|
17
|
+
class Test::Unit::TestCase
|
18
|
+
end
|
@@ -0,0 +1,253 @@
|
|
1
|
+
$:.unshift File.dirname(__FILE__)
|
2
|
+
require 'test_helper'
|
3
|
+
|
4
|
+
require 'fileutils'
|
5
|
+
require 'tempfile'
|
6
|
+
|
7
|
+
module Kytoon
|
8
|
+
module Providers
|
9
|
+
module CloudServersVPC
|
10
|
+
|
11
|
+
class ServerGroupTest < Test::Unit::TestCase
|
12
|
+
|
13
|
+
def setup
|
14
|
+
@tmp_dir=TmpDir.new_tmp_dir
|
15
|
+
ServerGroup.data_dir=@tmp_dir
|
16
|
+
end
|
17
|
+
|
18
|
+
def teardown
|
19
|
+
FileUtils.rm_rf(@tmp_dir)
|
20
|
+
end
|
21
|
+
|
22
|
+
TEST_JSON_CONFIG = %{{
|
23
|
+
"name": "test",
|
24
|
+
"domain_name": "vpc",
|
25
|
+
"description": "test description",
|
26
|
+
"servers": {
|
27
|
+
"login": {
|
28
|
+
"image_id": "51",
|
29
|
+
"flavor_id": "2",
|
30
|
+
"openvpn_server": "true"
|
31
|
+
},
|
32
|
+
"client1": {
|
33
|
+
"image_id": "69",
|
34
|
+
"flavor_id": "3"
|
35
|
+
}
|
36
|
+
}
|
37
|
+
}}
|
38
|
+
|
39
|
+
def test_server_new
|
40
|
+
sg=ServerGroup.new(:name => "test", :domain_name => "vpc", :description => "zz")
|
41
|
+
assert_equal "test", sg.name
|
42
|
+
assert_equal "zz", sg.description
|
43
|
+
assert_equal "vpc", sg.domain_name
|
44
|
+
assert_equal "172.19.0.0", sg.vpn_network
|
45
|
+
assert_equal "255.255.128.0", sg.vpn_subnet
|
46
|
+
end
|
47
|
+
|
48
|
+
def test_gateway_ip
|
49
|
+
sg=ServerGroup.from_xml(SERVER_GROUP_XML)
|
50
|
+
assert_equal "184.106.205.120", sg.gateway_ip
|
51
|
+
assert_equal 1759, sg.id
|
52
|
+
assert_equal "test description", sg.description
|
53
|
+
assert_equal "dan.prince", sg.owner_name
|
54
|
+
assert_equal "172.19.0.0", sg.vpn_network
|
55
|
+
assert_equal "255.255.128.0", sg.vpn_subnet
|
56
|
+
assert_equal 2, sg.servers.size
|
57
|
+
end
|
58
|
+
|
59
|
+
#def test_vpn_gateway_name
|
60
|
+
#sg=ServerGroup.from_xml(SERVER_GROUP_XML)
|
61
|
+
#assert_equal "login1", sg.vpn_gateway_name
|
62
|
+
#end
|
63
|
+
|
64
|
+
def test_server_group_from_json_config
|
65
|
+
sg=ServerGroup.from_json(TEST_JSON_CONFIG)
|
66
|
+
assert_equal "vpc", sg.domain_name
|
67
|
+
assert_equal "test", sg.name
|
68
|
+
assert_equal "test description", sg.description
|
69
|
+
assert_equal 2, sg.servers.size
|
70
|
+
assert_equal 1, sg.ssh_public_keys.size
|
71
|
+
|
72
|
+
# validate the login server
|
73
|
+
login_server=sg.server("login")
|
74
|
+
assert_equal "51", login_server.image_id
|
75
|
+
assert_equal "2", login_server.flavor_id
|
76
|
+
assert_equal true, login_server.openvpn_server?
|
77
|
+
|
78
|
+
# validate the client1 server
|
79
|
+
client1_server=sg.server("client1")
|
80
|
+
assert_equal "69", client1_server.image_id
|
81
|
+
assert_equal "3", client1_server.flavor_id
|
82
|
+
assert_equal false, client1_server.openvpn_server?
|
83
|
+
|
84
|
+
end
|
85
|
+
|
86
|
+
def test_server_group_from_xml
|
87
|
+
sg=ServerGroup.from_xml(SERVER_GROUP_XML)
|
88
|
+
assert_equal "mydomain.net", sg.domain_name
|
89
|
+
assert_equal "test", sg.name
|
90
|
+
assert_equal "test description", sg.description
|
91
|
+
assert_equal 2, sg.servers.size
|
92
|
+
assert_equal 1759, sg.id
|
93
|
+
end
|
94
|
+
|
95
|
+
def test_server_group_to_xml
|
96
|
+
sg=ServerGroup.from_xml(SERVER_GROUP_XML)
|
97
|
+
assert_equal "mydomain.net", sg.domain_name
|
98
|
+
assert_equal "test", sg.name
|
99
|
+
assert_equal "test description", sg.description
|
100
|
+
assert_equal 2, sg.servers.size
|
101
|
+
assert_equal 1759, sg.id
|
102
|
+
xml=sg.to_xml
|
103
|
+
end
|
104
|
+
|
105
|
+
def test_print_server_group
|
106
|
+
|
107
|
+
sg=ServerGroup.from_xml(SERVER_GROUP_XML)
|
108
|
+
tmp = Tempfile.open('kytoon')
|
109
|
+
begin
|
110
|
+
$stdout = tmp
|
111
|
+
sg.pretty_print
|
112
|
+
tmp.flush
|
113
|
+
output=IO.read(tmp.path)
|
114
|
+
$stdout = STDOUT
|
115
|
+
assert output =~ /login1/
|
116
|
+
assert output =~ /test1/
|
117
|
+
assert output =~ /184.106.205.120/
|
118
|
+
ensure
|
119
|
+
$stdout = STDOUT
|
120
|
+
end
|
121
|
+
|
122
|
+
end
|
123
|
+
|
124
|
+
def test_server_names
|
125
|
+
|
126
|
+
sg=ServerGroup.from_xml(SERVER_GROUP_XML)
|
127
|
+
names=sg.server_names
|
128
|
+
|
129
|
+
assert_equal 2, names.size
|
130
|
+
assert names.include?("login1")
|
131
|
+
assert names.include?("test1")
|
132
|
+
|
133
|
+
end
|
134
|
+
|
135
|
+
def test_get
|
136
|
+
|
137
|
+
tmp_dir=TmpDir.new_tmp_dir
|
138
|
+
File.open("#{tmp_dir}/1759.xml", 'w') do |f|
|
139
|
+
f.write(SERVER_GROUP_XML)
|
140
|
+
end
|
141
|
+
ServerGroup.data_dir=tmp_dir
|
142
|
+
|
143
|
+
Connection.stubs(:get).returns(SERVER_GROUP_XML)
|
144
|
+
|
145
|
+
sg=ServerGroup.get(:source => "cache")
|
146
|
+
assert_not_nil sg
|
147
|
+
assert_equal "test", sg.name
|
148
|
+
|
149
|
+
sg=ServerGroup.get(:id => "1759", :source => "cache")
|
150
|
+
assert_not_nil sg
|
151
|
+
assert_equal "test", sg.name
|
152
|
+
|
153
|
+
#nonexistent group from cache
|
154
|
+
assert_raises(RuntimeError) do
|
155
|
+
ServerGroup.get(:id => "1234", :source => "cache")
|
156
|
+
end
|
157
|
+
|
158
|
+
#invalid get source
|
159
|
+
assert_raises(RuntimeError) do
|
160
|
+
ServerGroup.get(:id => "1759", :source => "asdf")
|
161
|
+
end
|
162
|
+
|
163
|
+
end
|
164
|
+
|
165
|
+
def test_index_from_cache
|
166
|
+
|
167
|
+
tmp_dir=TmpDir.new_tmp_dir
|
168
|
+
File.open("#{tmp_dir}/1759.xml", 'w') do |f|
|
169
|
+
f.write(SERVER_GROUP_XML)
|
170
|
+
end
|
171
|
+
ServerGroup.data_dir=tmp_dir
|
172
|
+
|
173
|
+
server_groups = ServerGroup.index
|
174
|
+
|
175
|
+
assert_equal 1, server_groups.size
|
176
|
+
assert_equal 1759, server_groups[0].id
|
177
|
+
|
178
|
+
end
|
179
|
+
|
180
|
+
def test_index_from_remote
|
181
|
+
|
182
|
+
tmp_dir=TmpDir.new_tmp_dir
|
183
|
+
File.open("#{tmp_dir}/1759.xml", 'w') do |f|
|
184
|
+
f.write(SERVER_GROUP_XML)
|
185
|
+
end
|
186
|
+
ServerGroup.data_dir=tmp_dir
|
187
|
+
|
188
|
+
Connection.stubs(:get).returns(SERVER_GROUP_XML)
|
189
|
+
server_groups = ServerGroup.index(:source => "remote")
|
190
|
+
|
191
|
+
assert_equal 1, server_groups.size
|
192
|
+
assert_equal 1759, server_groups[0].id
|
193
|
+
|
194
|
+
end
|
195
|
+
|
196
|
+
def test_create
|
197
|
+
|
198
|
+
sg=ServerGroup.from_json(TEST_JSON_CONFIG)
|
199
|
+
|
200
|
+
tmp_dir=TmpDir.new_tmp_dir
|
201
|
+
File.open("#{tmp_dir}/1759.xml", 'w') do |f|
|
202
|
+
f.write(SERVER_GROUP_XML)
|
203
|
+
end
|
204
|
+
ServerGroup.data_dir=tmp_dir
|
205
|
+
|
206
|
+
Connection.stubs(:post).returns(SERVER_GROUP_XML)
|
207
|
+
Connection.stubs(:get).returns(SERVER_GROUP_XML)
|
208
|
+
sg = ServerGroup.create(sg)
|
209
|
+
assert_not_nil sg
|
210
|
+
assert_equal "mydomain.net", sg.domain_name
|
211
|
+
assert_equal "test", sg.name
|
212
|
+
assert_equal "test description", sg.description
|
213
|
+
assert_equal 2, sg.servers.size
|
214
|
+
assert_equal 1759, sg.id
|
215
|
+
|
216
|
+
end
|
217
|
+
|
218
|
+
def test_most_recent
|
219
|
+
|
220
|
+
File.open("#{ServerGroup.data_dir}/5.xml", 'w') do |f|
|
221
|
+
f.write(SERVER_GROUP_XML)
|
222
|
+
end
|
223
|
+
|
224
|
+
sg=ServerGroup.most_recent
|
225
|
+
|
226
|
+
assert_equal "mydomain.net", sg.domain_name
|
227
|
+
assert_equal 1759, sg.id
|
228
|
+
assert_equal 2, sg.servers.size
|
229
|
+
|
230
|
+
end
|
231
|
+
|
232
|
+
def test_cache_to_disk
|
233
|
+
|
234
|
+
sg=ServerGroup.from_xml(SERVER_GROUP_XML)
|
235
|
+
assert sg.cache_to_disk
|
236
|
+
assert File.exists?(File.join(ServerGroup.data_dir, "#{sg.id}.xml"))
|
237
|
+
|
238
|
+
end
|
239
|
+
|
240
|
+
def test_delete
|
241
|
+
|
242
|
+
sg=ServerGroup.from_xml(SERVER_GROUP_XML)
|
243
|
+
Connection.stubs(:delete).returns("")
|
244
|
+
sg.delete
|
245
|
+
assert_equal false, File.exists?(File.join(ServerGroup.data_dir, "#{sg.id}.xml"))
|
246
|
+
|
247
|
+
end
|
248
|
+
|
249
|
+
end
|
250
|
+
|
251
|
+
end
|
252
|
+
end
|
253
|
+
end
|