ovh-provisioner 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/.gitlab-ci.yml +23 -0
- data/.rspec +2 -0
- data/.ruby-version +1 -0
- data/CHANGELOG +7 -0
- data/CONTRIBUTING.md +62 -0
- data/Gemfile +6 -0
- data/LICENSE +202 -0
- data/README.md +107 -0
- data/Rakefile +24 -0
- data/bin/console +32 -0
- data/bin/ovh_provisioner +27 -0
- data/bin/setup +23 -0
- data/lib/ovh/provisioner.rb +43 -0
- data/lib/ovh/provisioner/api_list.rb +158 -0
- data/lib/ovh/provisioner/api_object/api_object.rb +125 -0
- data/lib/ovh/provisioner/api_object/dedicated_server.rb +225 -0
- data/lib/ovh/provisioner/api_object/domain_zone.rb +115 -0
- data/lib/ovh/provisioner/api_object/ip.rb +83 -0
- data/lib/ovh/provisioner/api_object/record.rb +48 -0
- data/lib/ovh/provisioner/api_object/vrack.rb +92 -0
- data/lib/ovh/provisioner/cli.rb +173 -0
- data/lib/ovh/provisioner/cli_domain.rb +138 -0
- data/lib/ovh/provisioner/cli_ip.rb +64 -0
- data/lib/ovh/provisioner/cli_vrack.rb +71 -0
- data/lib/ovh/provisioner/init.rb +77 -0
- data/lib/ovh/provisioner/self_cli.rb +81 -0
- data/lib/ovh/provisioner/spawner.rb +63 -0
- data/lib/ovh/provisioner/version.rb +24 -0
- data/ovh-provisioner.gemspec +53 -0
- data/spec/config.yml +53 -0
- data/spec/helpers/highline_helper.rb +36 -0
- data/spec/ovh/provisioner/cli_domain_spec.rb +140 -0
- data/spec/ovh/provisioner/cli_ip_spec.rb +90 -0
- data/spec/ovh/provisioner/cli_spec.rb +186 -0
- data/spec/ovh/provisioner/cli_vrack_spec.rb +83 -0
- data/spec/ovh/provisioner/stubs/domain_stubs.rb +204 -0
- data/spec/ovh/provisioner/stubs/ip_stubs.rb +152 -0
- data/spec/ovh/provisioner/stubs/server_stubs.rb +146 -0
- data/spec/ovh/provisioner/stubs/vrack_stubs.rb +87 -0
- data/spec/ovh/provisioner_spec.rb +25 -0
- data/spec/spec_helper.rb +47 -0
- metadata +350 -0
@@ -0,0 +1,138 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2015-2016 Sam4Mobile, 2017-2018 Make.org
|
5
|
+
#
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
7
|
+
# you may not use this file except in compliance with the License.
|
8
|
+
# You may obtain a copy of the License at
|
9
|
+
#
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
11
|
+
#
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
15
|
+
# See the License for the specific language governing permissions and
|
16
|
+
# limitations under the License.
|
17
|
+
#
|
18
|
+
|
19
|
+
require 'thor'
|
20
|
+
require 'pp'
|
21
|
+
require 'ruby-progressbar'
|
22
|
+
require 'highline/import'
|
23
|
+
|
24
|
+
module OVH
|
25
|
+
module Provisioner
|
26
|
+
# Command line for domain (actually domain/zone)
|
27
|
+
class CliDomain < Thor
|
28
|
+
# Exit 1 on failure
|
29
|
+
def self.exit_on_failure?
|
30
|
+
true
|
31
|
+
end
|
32
|
+
|
33
|
+
# options
|
34
|
+
SUBDOMAIN = [
|
35
|
+
:subdomain,
|
36
|
+
default: '',
|
37
|
+
desc: 'Record subdomain',
|
38
|
+
aliases: ['-d']
|
39
|
+
].freeze
|
40
|
+
|
41
|
+
TYPE = [
|
42
|
+
:type,
|
43
|
+
default: '',
|
44
|
+
desc: 'Record type',
|
45
|
+
aliases: ['-y']
|
46
|
+
].freeze
|
47
|
+
|
48
|
+
TARGET = [
|
49
|
+
:target,
|
50
|
+
default: '',
|
51
|
+
desc: 'Record target',
|
52
|
+
aliases: ['-t']
|
53
|
+
].freeze
|
54
|
+
|
55
|
+
TTL = [
|
56
|
+
:ttl,
|
57
|
+
default: '0',
|
58
|
+
desc: 'Record TTL',
|
59
|
+
aliases: ['-l']
|
60
|
+
].freeze
|
61
|
+
|
62
|
+
desc 'list', 'Print the list of your domains'
|
63
|
+
def list(*targets)
|
64
|
+
spawner = Provisioner.init(options)
|
65
|
+
puts spawner.get('DomainZone', *Cli.all(targets)).format
|
66
|
+
end
|
67
|
+
|
68
|
+
desc 'show domain', 'Show records in a domain'
|
69
|
+
def show(domain)
|
70
|
+
spawner = Provisioner.init(options)
|
71
|
+
zones = spawner.get('DomainZone', domain).list
|
72
|
+
zones.each { |z| puts z.details } if check_zone_input(domain, zones)
|
73
|
+
end
|
74
|
+
|
75
|
+
desc 'add domain', 'Add a record in a domain'
|
76
|
+
[SUBDOMAIN, TYPE, TARGET, TTL].map { |o| option(*o) }
|
77
|
+
def add(domain)
|
78
|
+
spawner = Provisioner.init(options)
|
79
|
+
zones = spawner.get('DomainZone', domain).list
|
80
|
+
return unless check_zone_input(domain, zones, false)
|
81
|
+
|
82
|
+
zone = zones.first
|
83
|
+
add_record(zone, options)
|
84
|
+
end
|
85
|
+
|
86
|
+
desc 'rm domain', 'Remove records in domain'
|
87
|
+
[SUBDOMAIN, TYPE, TARGET, TTL].map { |o| option(*o) }
|
88
|
+
def rm(domain)
|
89
|
+
spawner = Provisioner.init(options)
|
90
|
+
zones = spawner.get('DomainZone', domain).list
|
91
|
+
return unless check_zone_input(domain, zones, false)
|
92
|
+
|
93
|
+
zone = zones.first
|
94
|
+
matches = zone.filter_records(Provisioner.config)
|
95
|
+
rm_records(zone, matches)
|
96
|
+
end
|
97
|
+
|
98
|
+
no_commands do # rubocop:disable Metrics/BlockLength
|
99
|
+
def check_zone_input(search, zones, allow_many = true)
|
100
|
+
ok = true
|
101
|
+
if zones.empty?
|
102
|
+
puts "No registered services of your account match #{search}"
|
103
|
+
ok = false
|
104
|
+
end
|
105
|
+
if !allow_many && zones.size > 1
|
106
|
+
puts "Need one zone, got many: #{zones.map(&:id)}"
|
107
|
+
ok = false
|
108
|
+
end
|
109
|
+
ok
|
110
|
+
end
|
111
|
+
|
112
|
+
def add_record(zone, options)
|
113
|
+
sub = options['subdomain']
|
114
|
+
type = options['type'].upcase
|
115
|
+
target = options['target']
|
116
|
+
ttl = options['ttl']
|
117
|
+
Cli.ask_validation(
|
118
|
+
"You are going to add a record to #{zone.id}:",
|
119
|
+
" #{APIObject::Record.print(zone, sub, ttl, type, target)}"
|
120
|
+
)
|
121
|
+
puts zone.add_record(sub, type, target, ttl)
|
122
|
+
end
|
123
|
+
|
124
|
+
def rm_records(zone, matches)
|
125
|
+
if matches.list.empty?
|
126
|
+
puts 'Nothing to do…'
|
127
|
+
else
|
128
|
+
Cli.ask_validation(
|
129
|
+
"You are going to remove theses zones from #{zone.id}:",
|
130
|
+
zone.details(matches).lines[1..-1].join('')
|
131
|
+
)
|
132
|
+
puts zone.rm_records(matches)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2015-2016 Sam4Mobile, 2017-2018 Make.org
|
5
|
+
#
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
7
|
+
# you may not use this file except in compliance with the License.
|
8
|
+
# You may obtain a copy of the License at
|
9
|
+
#
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
11
|
+
#
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
15
|
+
# See the License for the specific language governing permissions and
|
16
|
+
# limitations under the License.
|
17
|
+
#
|
18
|
+
|
19
|
+
require 'thor'
|
20
|
+
require 'pp'
|
21
|
+
require 'ruby-progressbar'
|
22
|
+
require 'highline/import'
|
23
|
+
|
24
|
+
module OVH
|
25
|
+
module Provisioner
|
26
|
+
# The command line runner
|
27
|
+
class CliIP < Thor
|
28
|
+
# Exit 1 on failure
|
29
|
+
def self.exit_on_failure?
|
30
|
+
true
|
31
|
+
end
|
32
|
+
|
33
|
+
desc 'list', 'print the list of your OVH IPs'
|
34
|
+
def list(*targets)
|
35
|
+
spawner = Provisioner.init(options)
|
36
|
+
puts spawner.get('IP', *Cli.all(targets)).format('routed_to', 'kind')
|
37
|
+
end
|
38
|
+
|
39
|
+
desc 'set_reverse ip reverse', 'Set the reverse of the IP'
|
40
|
+
def set_reverse(ip, reverse)
|
41
|
+
spawner = Provisioner.init(options)
|
42
|
+
ips = spawner.get('IP', ip).list
|
43
|
+
return unless Cli.check_service_input(ip, ips, false)
|
44
|
+
|
45
|
+
ip = ips.first
|
46
|
+
ask = "You are going to set the reverse of #{ip.id} to #{reverse}"
|
47
|
+
Cli.ask_validation(ask)
|
48
|
+
puts ip.add_reverse(reverse)
|
49
|
+
end
|
50
|
+
|
51
|
+
desc 'rm_reverse ip', 'Remove the reverse of the IP'
|
52
|
+
def rm_reverse(ip)
|
53
|
+
spawner = Provisioner.init(options)
|
54
|
+
ips = spawner.get('IP', ip).list
|
55
|
+
return unless Cli.check_service_input(ip, ips, false)
|
56
|
+
|
57
|
+
ip = ips.first
|
58
|
+
ask = "You are going to remove the reverse of #{ip.id}"
|
59
|
+
Cli.ask_validation(ask)
|
60
|
+
puts ip.rm_reverse
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2015-2016 Sam4Mobile, 2017-2018 Make.org
|
5
|
+
#
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
7
|
+
# you may not use this file except in compliance with the License.
|
8
|
+
# You may obtain a copy of the License at
|
9
|
+
#
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
11
|
+
#
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
15
|
+
# See the License for the specific language governing permissions and
|
16
|
+
# limitations under the License.
|
17
|
+
#
|
18
|
+
|
19
|
+
require 'thor'
|
20
|
+
require 'pp'
|
21
|
+
require 'ruby-progressbar'
|
22
|
+
require 'highline/import'
|
23
|
+
|
24
|
+
module OVH
|
25
|
+
module Provisioner
|
26
|
+
# The command line runner
|
27
|
+
class CliVrack < Thor
|
28
|
+
# Exit 1 on failure
|
29
|
+
def self.exit_on_failure?
|
30
|
+
true
|
31
|
+
end
|
32
|
+
|
33
|
+
desc 'list', 'print the list of your dedicated servers'
|
34
|
+
def list(*targets)
|
35
|
+
spawner = Provisioner.init(options)
|
36
|
+
puts spawner.get('Vrack', *Cli.all(targets)).format
|
37
|
+
end
|
38
|
+
|
39
|
+
desc 'add vrack_id targets…', 'Add one/multiple servers in a vrack'
|
40
|
+
def add(vrack_id, *targets)
|
41
|
+
msg = 'You are going to add those servers to vrack'
|
42
|
+
execute_on_vrack(vrack_id, targets, :add, msg)
|
43
|
+
end
|
44
|
+
|
45
|
+
desc 'rm vrack_id targets_', 'Remove one/multiple servers from a vrack'
|
46
|
+
def rm(vrack_id, *targets)
|
47
|
+
msg = 'You are going to remove those servers from vrack'
|
48
|
+
execute_on_vrack(vrack_id, targets, :remove, msg)
|
49
|
+
end
|
50
|
+
|
51
|
+
no_commands do
|
52
|
+
def init_vrack(vrack_id, targets)
|
53
|
+
spawner = Provisioner.init(options)
|
54
|
+
servers = spawner.get('DedicatedServer', *Cli.all(targets))
|
55
|
+
vracks = spawner.get('Vrack', vrack_id)
|
56
|
+
[servers, vracks]
|
57
|
+
end
|
58
|
+
|
59
|
+
def execute_on_vrack(vrack_id, targets, method, msg)
|
60
|
+
servers, vracks = init_vrack(vrack_id, targets)
|
61
|
+
return unless vracks.list.size == 1
|
62
|
+
|
63
|
+
vrack = vracks.list.first
|
64
|
+
msg = "#{msg} #{vrack.id}(#{vrack.name}):"
|
65
|
+
Cli.ask_validation(msg, servers.format('vrack'))
|
66
|
+
servers.puts_each(method, [], vrack)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2015-2016 Sam4Mobile, 2017-2018 Make.org
|
5
|
+
#
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
7
|
+
# you may not use this file except in compliance with the License.
|
8
|
+
# You may obtain a copy of the License at
|
9
|
+
#
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
11
|
+
#
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
15
|
+
# See the License for the specific language governing permissions and
|
16
|
+
# limitations under the License.
|
17
|
+
#
|
18
|
+
|
19
|
+
require 'yaml'
|
20
|
+
require 'ovh/rest'
|
21
|
+
|
22
|
+
module OVH
|
23
|
+
# Load configuration and initialize client
|
24
|
+
module Provisioner
|
25
|
+
class << self
|
26
|
+
attr_reader :config
|
27
|
+
attr_reader :client
|
28
|
+
attr_reader :spawner
|
29
|
+
|
30
|
+
def init(options)
|
31
|
+
@config = load_config(options)
|
32
|
+
@client = create_client
|
33
|
+
@spawner = create_spawner
|
34
|
+
end
|
35
|
+
|
36
|
+
def load_config(options)
|
37
|
+
config_file = options['config_file']
|
38
|
+
begin
|
39
|
+
config = YAML.load_file config_file if File.exist? config_file
|
40
|
+
rescue StandardError => e
|
41
|
+
puts "#{e}\nCould not load configuration file: #{config_file}"
|
42
|
+
exit 1
|
43
|
+
end
|
44
|
+
check_missing((config || {}).merge(options))
|
45
|
+
end
|
46
|
+
|
47
|
+
def check_missing(config)
|
48
|
+
missing = %w[
|
49
|
+
app_key app_secret consumer_key api_url
|
50
|
+
].map { |key| config[key].nil? ? key : nil }.compact
|
51
|
+
|
52
|
+
return config if missing.empty?
|
53
|
+
|
54
|
+
puts "Please provide valid #{missing.join(', ')}"
|
55
|
+
exit 1
|
56
|
+
end
|
57
|
+
|
58
|
+
def create_client
|
59
|
+
return @client unless @client.nil?
|
60
|
+
|
61
|
+
OVH::REST.new(
|
62
|
+
config['app_key'],
|
63
|
+
config['app_secret'],
|
64
|
+
config['consumer_key'],
|
65
|
+
config['api_url']
|
66
|
+
)
|
67
|
+
end
|
68
|
+
|
69
|
+
def create_spawner
|
70
|
+
return @spawner unless @spawner.nil?
|
71
|
+
|
72
|
+
spawner = Spawner.new
|
73
|
+
Celluloid::Actor[Spawner::NAME] = spawner
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2015-2016 Sam4Mobile, 2017-2018 Make.org
|
5
|
+
#
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
7
|
+
# you may not use this file except in compliance with the License.
|
8
|
+
# You may obtain a copy of the License at
|
9
|
+
#
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
11
|
+
#
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
15
|
+
# See the License for the specific language governing permissions and
|
16
|
+
# limitations under the License.
|
17
|
+
#
|
18
|
+
|
19
|
+
require 'thor'
|
20
|
+
|
21
|
+
module OVH
|
22
|
+
module Provisioner
|
23
|
+
# The command line runner
|
24
|
+
class Cli < Thor
|
25
|
+
# Exit 1 on failure
|
26
|
+
def self.exit_on_failure?
|
27
|
+
true
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.ask_validation(question, what = nil)
|
31
|
+
say question
|
32
|
+
say what unless what.nil?
|
33
|
+
exit unless HighLine.agree('Do you want to proceed?')
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.check_service_input(search, services, allow_many = true)
|
37
|
+
ok = true
|
38
|
+
if services.empty?
|
39
|
+
puts "No registered services of your account match #{search}"
|
40
|
+
ok = false
|
41
|
+
end
|
42
|
+
if !allow_many && services.size > 1
|
43
|
+
puts "Need one service, got many: #{services.map(&:id)}"
|
44
|
+
ok = false
|
45
|
+
end
|
46
|
+
ok
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.all(targets)
|
50
|
+
targets.empty? ? [''] : targets
|
51
|
+
end
|
52
|
+
|
53
|
+
class_option(
|
54
|
+
:config_file,
|
55
|
+
desc: 'Configuration file to use',
|
56
|
+
default: File.join(Dir.home, '.config', 'ovh-provisioner.yml'),
|
57
|
+
aliases: ['-c']
|
58
|
+
)
|
59
|
+
class_option(
|
60
|
+
:app_key,
|
61
|
+
desc: 'Define/Override the Application Key',
|
62
|
+
aliases: ['-a']
|
63
|
+
)
|
64
|
+
class_option(
|
65
|
+
:app_secret,
|
66
|
+
desc: 'Define/Override the Application Secret',
|
67
|
+
aliases: ['-s']
|
68
|
+
)
|
69
|
+
class_option(
|
70
|
+
:consumer_key,
|
71
|
+
desc: 'Define/Override the Consumer Key',
|
72
|
+
aliases: ['-k']
|
73
|
+
)
|
74
|
+
class_option(
|
75
|
+
:api_url,
|
76
|
+
desc: 'Override the API url',
|
77
|
+
aliases: ['-u']
|
78
|
+
)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2015-2016 Sam4Mobile, 2017-2018 Make.org
|
5
|
+
#
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
7
|
+
# you may not use this file except in compliance with the License.
|
8
|
+
# You may obtain a copy of the License at
|
9
|
+
#
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
11
|
+
#
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
15
|
+
# See the License for the specific language governing permissions and
|
16
|
+
# limitations under the License.
|
17
|
+
#
|
18
|
+
|
19
|
+
require 'celluloid/current'
|
20
|
+
require 'ovh/provisioner/api_object/api_object'
|
21
|
+
|
22
|
+
module OVH
|
23
|
+
module Provisioner
|
24
|
+
# Is responsible for spawning other cells
|
25
|
+
class Spawner
|
26
|
+
include Celluloid
|
27
|
+
|
28
|
+
NAME = 'Spawner'
|
29
|
+
|
30
|
+
# Return and create on demand a given api_object or api_list
|
31
|
+
#
|
32
|
+
# - class_name is an api_object class name
|
33
|
+
# - args is an array of arguments used to object creation
|
34
|
+
# - parent is the requester (like a vrack asking for its tasks)
|
35
|
+
# - id is the id of the api_object (nil for api_list)
|
36
|
+
#
|
37
|
+
# Example:
|
38
|
+
# - get('Vrack', id: 'pn-123'): Vrack.new('pn-123')
|
39
|
+
# - get('Task', parent: 'vrack/pn-12', id: '98'):
|
40
|
+
# Task('98', 'vrack/pn-12')
|
41
|
+
# - get('DedicatedServer', '03'): APIList.new(DedicatedServer, '03')
|
42
|
+
def get(class_name, *args, parent: nil, id: nil)
|
43
|
+
cell_name = "#{parent}:#{class_name}@#{id}##{args}"
|
44
|
+
cell = Actor[cell_name.to_sym] ||= create(class_name, parent, id, args)
|
45
|
+
cell.init_properties
|
46
|
+
cell
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def create(class_name, parent, id, args)
|
52
|
+
exclusive do
|
53
|
+
cell_class = APIObject.const_get(class_name.to_sym)
|
54
|
+
if id.nil?
|
55
|
+
APIList.new(cell_class, parent, *args)
|
56
|
+
else
|
57
|
+
cell_class.new(id, parent, *args)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|