kytoon 1.0.0
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.
- 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
|