kitchen-yansible 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/kitchen-yansible.gemspec +59 -0
- data/lib/kitchen-yansible/tools/dependencies.rb +90 -0
- data/lib/kitchen-yansible/tools/exec.rb +111 -0
- data/lib/kitchen-yansible/tools/files.rb +179 -0
- data/lib/kitchen-yansible/tools/install.rb +234 -0
- data/lib/kitchen-yansible/tools/install/amazon.rb +67 -0
- data/lib/kitchen-yansible/tools/install/debian.rb +201 -0
- data/lib/kitchen-yansible/tools/install/fedora.rb +33 -0
- data/lib/kitchen-yansible/tools/install/rhel.rb +225 -0
- data/lib/kitchen-yansible/tools/install/windows.rb +122 -0
- data/lib/kitchen-yansible/version.rb +25 -0
- data/lib/kitchen/provisioner/yansible.rb +243 -0
- metadata +101 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: da916e8bba8652a388e29c59ae55a3b284f235079feaf3ab5c5be934abe1aae2
|
4
|
+
data.tar.gz: 3cfd7f8c238238cec9d607dbd4e1fe2b5be81983524bce8d57af1f811d90c3d2
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 66ff510329b64430d75ae50aba3580e862d434ee9dd5788c47d99b6d201c5a8477b88a5c5b94207b892139758f544feb32efb72a6b9f6b88114eddae1c85ace6
|
7
|
+
data.tar.gz: 076f4e1cac5146328c00a9bf261845d6f9815358ad863cdca8383209ab11acd58d9cc254f881ab7bfae9def630bf3a9cfe050f20ae4b095fbbc7954a0e30797e
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# Author: Eugene Akhmetkhanov <axmetishe+github@gmail.com>
|
2
|
+
# Date: 03-01-2020
|
3
|
+
#
|
4
|
+
# Licensed to the Apache Software Foundation (ASF) under one
|
5
|
+
# or more contributor license agreements. See the NOTICE file
|
6
|
+
# distributed with this work for additional information
|
7
|
+
# regarding copyright ownership. The ASF licenses this file
|
8
|
+
# to you under the Apache License, Version 2.0 (the
|
9
|
+
# "License"); you may not use this file except in compliance
|
10
|
+
# with the License. You may obtain a copy of the License at
|
11
|
+
#
|
12
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
13
|
+
#
|
14
|
+
# Unless required by applicable law or agreed to in writing,
|
15
|
+
# software distributed under the License is distributed on an
|
16
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
17
|
+
# KIND, either express or implied. See the License for the
|
18
|
+
# specific language governing permissions and limitations
|
19
|
+
# under the License.
|
20
|
+
|
21
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __FILE__)
|
22
|
+
require 'kitchen-yansible/version'
|
23
|
+
|
24
|
+
Gem::Specification.new do |s|
|
25
|
+
s.name = 'kitchen-yansible'
|
26
|
+
s.license = 'Apache-2.0'
|
27
|
+
s.version = Kitchen::Yansible::VERSION
|
28
|
+
s.authors = ['Eugene Akhmetkhanov']
|
29
|
+
s.email = ['axmetishe+github@gmail.com']
|
30
|
+
s.homepage = 'https://github.com/axmetishe/kitchen-yansible'
|
31
|
+
s.summary = 'Yet Another Ansible Test-Kitchen Provisioner'
|
32
|
+
s.files = (Dir.glob('{lib}/**/*') + ['kitchen-yansible.gemspec']).sort
|
33
|
+
s.platform = Gem::Platform::RUBY
|
34
|
+
s.require_paths = ['lib']
|
35
|
+
s.rubyforge_project = '[none]'
|
36
|
+
s.description = <<-EOF
|
37
|
+
Yet Another Ansible Test Kitchen Provisioner
|
38
|
+
|
39
|
+
Features:
|
40
|
+
- Local and remote execution using single provisioner
|
41
|
+
- Local Ansible sandbox configuration using Virtualenv
|
42
|
+
- Local execution using Ansible from PATH
|
43
|
+
- Remote Ansible installation via Pip and Virtualenv
|
44
|
+
- Dependency management
|
45
|
+
- Path based
|
46
|
+
- Git repositories
|
47
|
+
- Drivers
|
48
|
+
- Docker
|
49
|
+
- Vagrant
|
50
|
+
- Platforms
|
51
|
+
- RHEL-based - CentOS, Fedora, Amazon Linux, Oracle Linux
|
52
|
+
- Debian-based - Debian, Ubuntu
|
53
|
+
- Windows via PS Remoting (Local executor only)
|
54
|
+
|
55
|
+
EOF
|
56
|
+
|
57
|
+
s.add_runtime_dependency 'test-kitchen', '~> 2.0'
|
58
|
+
s.add_runtime_dependency 'rugged', '~> 0.25'
|
59
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# Author: Eugene Akhmetkhanov <axmetishe+github@gmail.com>
|
2
|
+
# Date: 11-01-2020
|
3
|
+
#
|
4
|
+
# Licensed to the Apache Software Foundation (ASF) under one
|
5
|
+
# or more contributor license agreements. See the NOTICE file
|
6
|
+
# distributed with this work for additional information
|
7
|
+
# regarding copyright ownership. The ASF licenses this file
|
8
|
+
# to you under the Apache License, Version 2.0 (the
|
9
|
+
# "License"); you may not use this file except in compliance
|
10
|
+
# with the License. You may obtain a copy of the License at
|
11
|
+
#
|
12
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
13
|
+
#
|
14
|
+
# Unless required by applicable law or agreed to in writing,
|
15
|
+
# software distributed under the License is distributed on an
|
16
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
17
|
+
# KIND, either express or implied. See the License for the
|
18
|
+
# specific language governing permissions and limitations
|
19
|
+
# under the License.
|
20
|
+
|
21
|
+
require 'rugged'
|
22
|
+
|
23
|
+
module Kitchen
|
24
|
+
module Yansible
|
25
|
+
module Tools
|
26
|
+
module Dependencies
|
27
|
+
def git_clone(name, url, path)
|
28
|
+
info("Cloning '#{name}' Git repository.")
|
29
|
+
Rugged::Repository.clone_at(url, path, { :ignore_cert_errors => true })
|
30
|
+
end
|
31
|
+
|
32
|
+
def prepare_dependencies(dependencies)
|
33
|
+
dependencies.each do |dependency|
|
34
|
+
info("Processing '#{dependency[:name]}' dependency.")
|
35
|
+
dependency_target_path = File.join(dependencies_tmp_dir, dependency[:name])
|
36
|
+
if dependency.key?(:path)
|
37
|
+
info('Processing as path type.')
|
38
|
+
if File.exist?(dependency[:path])
|
39
|
+
copy_dirs(dependency[:path], dependency_target_path)
|
40
|
+
else
|
41
|
+
warn("Dependency path '#{dependency[:path]}' doesn't exists. Omitting copy operation.")
|
42
|
+
end
|
43
|
+
end
|
44
|
+
if dependency.key?(:repo)
|
45
|
+
if dependency[:repo].downcase == 'git'
|
46
|
+
info('Processing as Git repository.')
|
47
|
+
begin
|
48
|
+
repo = Rugged::Repository.new(dependency_target_path)
|
49
|
+
if repo.remotes.first.url.eql?(dependency[:url])
|
50
|
+
warn("Dependency cloned already.")
|
51
|
+
else
|
52
|
+
warn("Removing directory #{dependency_target_path} due to repository origin difference.")
|
53
|
+
FileUtils.remove_entry_secure(dependency_target_path)
|
54
|
+
git_clone(dependency[:name], dependency[:url], dependency_target_path)
|
55
|
+
end
|
56
|
+
rescue
|
57
|
+
if File.exist?(dependency_target_path)
|
58
|
+
warn("Dependency path '#{dependency_target_path}' is not a valid Git repository. Removing then.")
|
59
|
+
FileUtils.remove_entry_secure(dependency_target_path)
|
60
|
+
end
|
61
|
+
repo = git_clone(dependency[:name], dependency[:url], dependency_target_path)
|
62
|
+
end
|
63
|
+
|
64
|
+
raw_ref = dependency.key?(:ref) ? dependency[:ref] : 'master'
|
65
|
+
begin
|
66
|
+
repo.rev_parse(raw_ref)
|
67
|
+
rescue
|
68
|
+
message = unindent(<<-MSG)
|
69
|
+
|
70
|
+
===============================================================================
|
71
|
+
Invalid Git reference - #{raw_ref}
|
72
|
+
Please check '#{dependency[:name]}' dependency configuration.
|
73
|
+
===============================================================================
|
74
|
+
MSG
|
75
|
+
raise UserError, message
|
76
|
+
end
|
77
|
+
|
78
|
+
info("Resetting '#{dependency[:name]}' repository to '#{raw_ref}' reference.")
|
79
|
+
repo.checkout(raw_ref, {:strategy => :force})
|
80
|
+
repo.close
|
81
|
+
else
|
82
|
+
raise UserError, "Working with '#{dependency[:repo]}' repository is not implemented yet."
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end unless dependencies.nil?
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
# Author: Eugene Akhmetkhanov <axmetishe+github@gmail.com>
|
2
|
+
# Date: 07-01-2020
|
3
|
+
#
|
4
|
+
# Licensed to the Apache Software Foundation (ASF) under one
|
5
|
+
# or more contributor license agreements. See the NOTICE file
|
6
|
+
# distributed with this work for additional information
|
7
|
+
# regarding copyright ownership. The ASF licenses this file
|
8
|
+
# to you under the Apache License, Version 2.0 (the
|
9
|
+
# "License"); you may not use this file except in compliance
|
10
|
+
# with the License. You may obtain a copy of the License at
|
11
|
+
#
|
12
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
13
|
+
#
|
14
|
+
# Unless required by applicable law or agreed to in writing,
|
15
|
+
# software distributed under the License is distributed on an
|
16
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
17
|
+
# KIND, either express or implied. See the License for the
|
18
|
+
# specific language governing permissions and limitations
|
19
|
+
# under the License.
|
20
|
+
|
21
|
+
require 'open3'
|
22
|
+
|
23
|
+
module Kitchen
|
24
|
+
module Yansible
|
25
|
+
module Tools
|
26
|
+
module Exec
|
27
|
+
def unindent(s)
|
28
|
+
s.gsub(/^#{s.scan(/^[ \t]+(?=\S)/).min}/, '')
|
29
|
+
end
|
30
|
+
|
31
|
+
def check_command(command, args: '')
|
32
|
+
"command -v #{command}" + "#{(" #{args}" unless args.empty?)}"
|
33
|
+
end
|
34
|
+
|
35
|
+
def command_exists(command)
|
36
|
+
check_command(command, :args => '&>/dev/null')
|
37
|
+
end
|
38
|
+
|
39
|
+
def local_command_path(command, args: '')
|
40
|
+
system(check_command(command, args))
|
41
|
+
end
|
42
|
+
|
43
|
+
def local_command_exists(command)
|
44
|
+
"#{local_command_path(command, :args => '&>/dev/null')}"
|
45
|
+
end
|
46
|
+
|
47
|
+
def print_cmd_parameters(command, env = {})
|
48
|
+
env_vars = []
|
49
|
+
env.each { |k,v| env_vars.push("#{k}=#{v}") }
|
50
|
+
message = unindent(<<-MSG)
|
51
|
+
|
52
|
+
===============================================================================
|
53
|
+
Environment:
|
54
|
+
#{env_vars.join("\n ")}
|
55
|
+
Command line:
|
56
|
+
#{command}
|
57
|
+
===============================================================================
|
58
|
+
MSG
|
59
|
+
debug(message)
|
60
|
+
end
|
61
|
+
|
62
|
+
def print_cmd_error(stderr, proc)
|
63
|
+
message = unindent(<<-MSG)
|
64
|
+
|
65
|
+
===============================================================================
|
66
|
+
Command returned '#{proc.exitstatus}'.
|
67
|
+
stderr: '#{stderr.read}'
|
68
|
+
===============================================================================
|
69
|
+
MSG
|
70
|
+
debug(message)
|
71
|
+
raise UserError, message unless proc.success?
|
72
|
+
end
|
73
|
+
|
74
|
+
def execute_local_command(command, env: {}, opts: {}, print_stdout: false, return_stdout: false)
|
75
|
+
print_cmd_parameters(command, env)
|
76
|
+
|
77
|
+
# noinspection RubyUnusedLocalVariable
|
78
|
+
Open3.popen3(env, command, opts) { |stdin, stdout, stderr, thread|
|
79
|
+
if print_stdout
|
80
|
+
while (line = stdout.gets)
|
81
|
+
puts line
|
82
|
+
end
|
83
|
+
end
|
84
|
+
proc = thread.value
|
85
|
+
|
86
|
+
print_cmd_error(stderr, proc)
|
87
|
+
return_stdout ? stdout.read : proc.success?
|
88
|
+
}
|
89
|
+
end
|
90
|
+
|
91
|
+
# Helpers
|
92
|
+
def sudo_env(pm)
|
93
|
+
s = @config[:https_proxy] ? "https_proxy=#{@config[:https_proxy]}" : nil
|
94
|
+
p = @config[:http_proxy] ? "http_proxy=#{@config[:http_proxy]}" : nil
|
95
|
+
n = @config[:no_proxy] ? "no_proxy=#{@config[:no_proxy]}" : nil
|
96
|
+
p || s ? "#{sudo('env')} #{p} #{s} #{n} #{pm}" : "#{sudo(pm)}"
|
97
|
+
end
|
98
|
+
|
99
|
+
# Taken from https://github.com/test-kitchen/test-kitchen/blob/master/lib/kitchen/provisioner/base.rb
|
100
|
+
def sudo(script)
|
101
|
+
"sudo -E #{script}"
|
102
|
+
end
|
103
|
+
|
104
|
+
def detect_platform
|
105
|
+
@instance.driver.diagnose[:name] == 'docker' ?
|
106
|
+
@instance.driver.diagnose[:platform].to_s : @instance.platform.name.to_s
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,179 @@
|
|
1
|
+
# Author: Eugene Akhmetkhanov <axmetishe+github@gmail.com>
|
2
|
+
# Date: 07-01-2020
|
3
|
+
#
|
4
|
+
# Licensed to the Apache Software Foundation (ASF) under one
|
5
|
+
# or more contributor license agreements. See the NOTICE file
|
6
|
+
# distributed with this work for additional information
|
7
|
+
# regarding copyright ownership. The ASF licenses this file
|
8
|
+
# to you under the Apache License, Version 2.0 (the
|
9
|
+
# "License"); you may not use this file except in compliance
|
10
|
+
# with the License. You may obtain a copy of the License at
|
11
|
+
#
|
12
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
13
|
+
#
|
14
|
+
# Unless required by applicable law or agreed to in writing,
|
15
|
+
# software distributed under the License is distributed on an
|
16
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
17
|
+
# KIND, either express or implied. See the License for the
|
18
|
+
# specific language governing permissions and limitations
|
19
|
+
# under the License.
|
20
|
+
|
21
|
+
module Kitchen
|
22
|
+
module Yansible
|
23
|
+
module Tools
|
24
|
+
module Files
|
25
|
+
ANSIBLE_INVENTORY = "inventory.yml"
|
26
|
+
|
27
|
+
def inventory_file
|
28
|
+
File.join(instance_tmp_dir, ANSIBLE_INVENTORY)
|
29
|
+
end
|
30
|
+
|
31
|
+
def remote_file_path(file_path, fallback: nil)
|
32
|
+
if @config[:remote_executor]
|
33
|
+
File.join(@config[:root_path], file_path)
|
34
|
+
else
|
35
|
+
fallback.nil? ? file_path : fallback
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def prepare_ansible_config
|
40
|
+
if @config[:ansible_config]
|
41
|
+
copy_files(@config[:ansible_config], File.join(sandbox_path, @config[:ansible_config]))
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def prepare_playbook_file
|
46
|
+
if @config[:remote_executor]
|
47
|
+
copy_files(@config[:playbook], File.join(sandbox_path, @config[:playbook]))
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def prepare_inventory_file
|
52
|
+
if @config[:remote_executor]
|
53
|
+
copy_files(inventory_file, File.join(sandbox_path, ANSIBLE_INVENTORY))
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def generate_sandbox_path(directory)
|
58
|
+
path = File.join(sandbox_path, directory)
|
59
|
+
Dir.mkdir(path) unless File.exist?(path)
|
60
|
+
path
|
61
|
+
end
|
62
|
+
|
63
|
+
def executor_tmp_dir
|
64
|
+
if !@executor_tmp_dir && !instance.nil?
|
65
|
+
@executor_tmp_dir = File.join(config[:kitchen_root], %w[ .kitchen yansible ])
|
66
|
+
end
|
67
|
+
Dir.mkdir(@executor_tmp_dir) unless File.exist?(@executor_tmp_dir)
|
68
|
+
@executor_tmp_dir
|
69
|
+
end
|
70
|
+
|
71
|
+
def instance_tmp_dir
|
72
|
+
if !@instance_tmp_dir && !instance.nil?
|
73
|
+
@instance_tmp_dir = File.join(executor_tmp_dir, @instance.name)
|
74
|
+
end
|
75
|
+
Dir.mkdir(@instance_tmp_dir) unless File.exist?(@instance_tmp_dir)
|
76
|
+
@instance_tmp_dir
|
77
|
+
end
|
78
|
+
|
79
|
+
def dependencies_tmp_dir
|
80
|
+
if !@dependencies_tmp_dir && !instance.nil?
|
81
|
+
@dependencies_tmp_dir = File.join(instance_tmp_dir, 'dependencies')
|
82
|
+
end
|
83
|
+
Dir.mkdir(@dependencies_tmp_dir) unless File.exist?(@dependencies_tmp_dir)
|
84
|
+
@dependencies_tmp_dir
|
85
|
+
end
|
86
|
+
|
87
|
+
def venv_root
|
88
|
+
if !@venv_root && !instance.nil?
|
89
|
+
@venv_root = File.join(instance_tmp_dir, 'venv')
|
90
|
+
end
|
91
|
+
@venv_root
|
92
|
+
end
|
93
|
+
|
94
|
+
def generate_inventory(inventory_file, remote: false)
|
95
|
+
connection = @instance.transport.instance_variable_get(:@connection_options)
|
96
|
+
transport_conf = @instance.transport.diagnose
|
97
|
+
host_conn_vars = {}
|
98
|
+
|
99
|
+
debug("===> Connection options")
|
100
|
+
debug(connection.to_s)
|
101
|
+
debug("===> Transport options")
|
102
|
+
debug(transport_conf.to_s)
|
103
|
+
if remote
|
104
|
+
debug("Generating inventory stub for execution on remote target")
|
105
|
+
host_conn_vars['ansible_connection'] = 'local'
|
106
|
+
host_conn_vars['ansible_host'] = 'localhost'
|
107
|
+
else
|
108
|
+
debug("Generating inventory for execution on local host with remote targets")
|
109
|
+
host_conn_vars['ansible_connection'] = transport_conf[:name] if transport_conf[:name]
|
110
|
+
host_conn_vars['ansible_password'] = connection[:password] if connection[:password]
|
111
|
+
|
112
|
+
case transport_conf[:name]
|
113
|
+
when 'winrm'
|
114
|
+
host_conn_vars['ansible_host'] = URI.parse(connection[:endpoint]).hostname
|
115
|
+
host_conn_vars['ansible_user'] = connection[:user] if connection[:user]
|
116
|
+
host_conn_vars['ansible_winrm_transport'] = @config[:ansible_winrm_auth_transport] if @config[:ansible_winrm_auth_transport]
|
117
|
+
host_conn_vars['ansible_winrm_scheme'] = transport_conf[:winrm_transport] == :ssl ? 'https' : 'http'
|
118
|
+
host_conn_vars['ansible_winrm_server_cert_validation'] = @config[:ansible_winrm_cert_validation] if @config[:ansible_winrm_cert_validation]
|
119
|
+
when 'ssh'
|
120
|
+
host_conn_vars['ansible_host'] = connection[:hostname]
|
121
|
+
host_conn_vars['ansible_user'] = connection[:username] if connection[:username]
|
122
|
+
host_conn_vars['ansible_port'] = connection[:port] if connection[:port]
|
123
|
+
host_conn_vars['ansible_ssh_retries'] = connection[:connection_retries] if connection[:connection_retries]
|
124
|
+
host_conn_vars['ansible_private_key_file'] = connection[:keys].first if connection[:keys]
|
125
|
+
host_conn_vars['ansible_host_key_checking'] = @config[:ansible_host_key_checking] if @config[:ansible_host_key_checking]
|
126
|
+
else
|
127
|
+
message = unindent(<<-MSG)
|
128
|
+
|
129
|
+
===============================================================================
|
130
|
+
Unsupported transport - #{transport_conf[:name]}
|
131
|
+
SSH and WinRM transports are allowed.
|
132
|
+
===============================================================================
|
133
|
+
MSG
|
134
|
+
raise UserError, message
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
# noinspection RubyStringKeysInHashInspection
|
139
|
+
inv = { 'all' => { 'hosts' => { @instance.name => host_conn_vars } } }
|
140
|
+
|
141
|
+
File.open(inventory_file, 'w') do |file|
|
142
|
+
file.write inv.to_yaml
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
def copy_files(src, dst, overwrite: true)
|
147
|
+
debug("Copy '#{src}' to '#{dst}'")
|
148
|
+
|
149
|
+
FileUtils.copy_entry(src, dst, remove_destination=overwrite)
|
150
|
+
end
|
151
|
+
|
152
|
+
def copy_dirs(src, dst, reject: '.git')
|
153
|
+
expand_path=File.expand_path(src)
|
154
|
+
if File.exist?(expand_path)
|
155
|
+
debug("Copy '#{src}' to '#{dst}'.")
|
156
|
+
debug("'#{src}' expanded to '#{expand_path}'")
|
157
|
+
Dir.glob("#{expand_path}/**/{*,.*}").reject{|f| f[reject]}.each do |file|
|
158
|
+
target = dst + file.sub(expand_path, '')
|
159
|
+
if File.file?(file)
|
160
|
+
FileUtils.copy_entry(file, target, remove_destination: true)
|
161
|
+
else
|
162
|
+
FileUtils.mkdir_p(target) unless File.exist?(target)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
else
|
166
|
+
debug("Path '#{src}' doesn't exists. Omitting copy operation.")
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def copy_dirs_to_sandbox(src, dst: src, reject: '.git')
|
171
|
+
dest = generate_sandbox_path(dst)
|
172
|
+
debug("'#{src}' => '#{dest}', reject => '#{reject}'.")
|
173
|
+
|
174
|
+
copy_dirs(src, dest, reject: reject)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|