2019-06-11 22:24:35 +00:00
|
|
|
|
require "helper"
|
|
|
|
|
require "train"
|
2016-03-25 00:31:19 +00:00
|
|
|
|
|
2019-05-29 22:20:08 +00:00
|
|
|
|
ENV["CHEF_LICENSE"] = "accept-no-persist"
|
|
|
|
|
|
2019-06-11 22:24:35 +00:00
|
|
|
|
CMD = Train.create("local", command_runner: :generic).connection
|
2019-05-29 09:42:26 +00:00
|
|
|
|
|
2016-03-25 00:31:19 +00:00
|
|
|
|
class Module
|
|
|
|
|
include Minitest::Spec::DSL
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
module FunctionalHelper
|
2019-05-31 21:59:06 +00:00
|
|
|
|
extend Minitest::Spec::DSL
|
2019-10-18 09:06:35 +00:00
|
|
|
|
extend Minitest::Guard
|
|
|
|
|
|
2018-11-08 17:00:14 +00:00
|
|
|
|
let(:repo_path) do
|
2020-12-18 16:49:35 +00:00
|
|
|
|
path = File.expand_path("../..", __dir__)
|
2018-11-08 17:00:14 +00:00
|
|
|
|
# fix for vagrant repo pathing
|
2019-08-13 22:46:39 +00:00
|
|
|
|
path.gsub!("//vboxsvr", "C:") if is_windows?
|
2018-11-08 17:00:14 +00:00
|
|
|
|
path
|
|
|
|
|
end
|
2019-06-11 22:24:35 +00:00
|
|
|
|
let(:inspec_path) { File.join(repo_path, "inspec-bin", "bin", "inspec") }
|
2019-04-30 22:33:07 +00:00
|
|
|
|
libdir = File.expand_path "lib"
|
|
|
|
|
let(:exec_inspec) { [Gem.ruby, "-I#{libdir}", inspec_path].join " " }
|
2019-11-09 03:08:20 +00:00
|
|
|
|
let(:mock_path) { File.join(repo_path, "test", "fixtures") }
|
2019-06-11 22:24:35 +00:00
|
|
|
|
let(:profile_path) { File.join(mock_path, "profiles") }
|
|
|
|
|
let(:examples_path) { File.join(profile_path, "old-examples") }
|
2019-07-24 18:10:15 +00:00
|
|
|
|
let(:integration_test_path) { File.join(repo_path, "test", "integration", "default") }
|
2020-01-28 23:52:02 +00:00
|
|
|
|
let(:all_profiles) { Dir.glob("#{profile_path}/**/inspec.yml") }
|
2019-07-24 18:10:15 +00:00
|
|
|
|
let(:all_profile_folders) { all_profiles.map { |path| File.dirname(path) } }
|
|
|
|
|
|
2020-01-28 23:52:02 +00:00
|
|
|
|
let(:complete_profile) { "#{profile_path}/complete-profile" }
|
2019-06-11 22:24:35 +00:00
|
|
|
|
let(:example_profile) { File.join(examples_path, "profile") }
|
|
|
|
|
let(:meta_profile) { File.join(examples_path, "meta-profile") }
|
2019-10-11 05:47:02 +00:00
|
|
|
|
let(:example_control) { File.join(example_profile, "controls", "example-tmp.rb") }
|
2019-06-11 22:24:35 +00:00
|
|
|
|
let(:inheritance_profile) { File.join(examples_path, "inheritance") }
|
2021-05-31 16:08:30 +00:00
|
|
|
|
let(:shell_inheritance_profile) { File.join(repo_path, "test", "fixtures", "profiles", "dependencies", "shell-inheritance") }
|
2019-06-11 22:24:35 +00:00
|
|
|
|
let(:failure_control) { File.join(profile_path, "failures", "controls", "failures.rb") }
|
|
|
|
|
let(:simple_inheritance) { File.join(profile_path, "simple-inheritance") }
|
|
|
|
|
let(:sensitive_profile) { File.join(examples_path, "profile-sensitive") }
|
|
|
|
|
let(:config_dir_path) { File.join(mock_path, "config_dirs") }
|
|
|
|
|
|
|
|
|
|
let(:dst) do
|
2016-03-25 00:31:19 +00:00
|
|
|
|
# create a temporary path, but we only want an auto-clean helper
|
|
|
|
|
# so remove the file and give back the path
|
2019-06-11 22:24:35 +00:00
|
|
|
|
res = Tempfile.new("inspec-shred")
|
2018-09-14 00:19:02 +00:00
|
|
|
|
res.close
|
2016-03-25 00:31:19 +00:00
|
|
|
|
FileUtils.rm(res.path)
|
|
|
|
|
TMP_CACHE[res.path] = res
|
2019-06-11 22:24:35 +00:00
|
|
|
|
end
|
2016-03-25 00:31:19 +00:00
|
|
|
|
|
2019-10-18 09:06:35 +00:00
|
|
|
|
root_dir = windows? ? "C:" : "/etc"
|
|
|
|
|
ROOT_LICENSE_PATH = "#{root_dir}/chef/accepted_licenses/inspec".freeze
|
2019-08-06 22:39:47 +00:00
|
|
|
|
|
2019-05-30 18:17:19 +00:00
|
|
|
|
def without_license
|
|
|
|
|
ENV.delete "CHEF_LICENSE"
|
|
|
|
|
|
2019-08-06 22:39:47 +00:00
|
|
|
|
FileUtils.rm_f ROOT_LICENSE_PATH
|
|
|
|
|
|
2019-05-30 18:17:19 +00:00
|
|
|
|
yield
|
2019-08-06 22:39:47 +00:00
|
|
|
|
|
|
|
|
|
FileUtils.rm_f ROOT_LICENSE_PATH
|
2019-05-30 18:17:19 +00:00
|
|
|
|
ensure
|
|
|
|
|
ENV["CHEF_LICENSE"] = "accept-no-persist"
|
|
|
|
|
end
|
|
|
|
|
|
2019-06-11 22:24:35 +00:00
|
|
|
|
def assert_exit_code(exp, cmd)
|
|
|
|
|
exp = 1 if windows? && (exp != 0)
|
2019-06-04 06:08:14 +00:00
|
|
|
|
assert_equal exp, cmd.exit_status
|
|
|
|
|
end
|
|
|
|
|
|
2018-09-12 22:04:16 +00:00
|
|
|
|
def convert_windows_output(text)
|
|
|
|
|
text = text.force_encoding("UTF-8")
|
2019-06-11 22:24:35 +00:00
|
|
|
|
text.gsub!("[PASS]", "✔")
|
2018-09-12 22:04:16 +00:00
|
|
|
|
text.gsub!("\033[0;1;32m", "\033[38;5;41m")
|
2019-06-11 22:24:35 +00:00
|
|
|
|
text.gsub!("[SKIP]", "↺")
|
2018-09-12 22:04:16 +00:00
|
|
|
|
text.gsub!("\033[0;37m", "\033[38;5;247m")
|
2019-06-11 22:24:35 +00:00
|
|
|
|
text.gsub!("[FAIL]", "×")
|
2018-09-12 22:04:16 +00:00
|
|
|
|
text.gsub!("\033[0;1;31m", "\033[38;5;9m")
|
|
|
|
|
end
|
|
|
|
|
|
2018-11-08 17:00:14 +00:00
|
|
|
|
def self.is_windows?
|
2019-06-11 22:24:35 +00:00
|
|
|
|
RbConfig::CONFIG["host_os"] =~ /mswin|mingw|cygwin/
|
2018-09-12 22:04:16 +00:00
|
|
|
|
end
|
|
|
|
|
|
2018-11-08 17:00:14 +00:00
|
|
|
|
def is_windows?
|
|
|
|
|
FunctionalHelper.is_windows?
|
|
|
|
|
end
|
|
|
|
|
|
2019-10-21 23:13:03 +00:00
|
|
|
|
def stderr_ignore_deprecations(result)
|
|
|
|
|
stderr = result.stderr
|
|
|
|
|
suffix = stderr.end_with?("\n") ? "\n" : ""
|
|
|
|
|
stderr.split("\n").reject { |l| l.include? " DEPRECATION: " }.join("\n") + suffix
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def assert_json_controls_passing(_result = nil) # dummy arg
|
|
|
|
|
# Strategy: assemble an array of tests that failed or skipped, and insist it is empty
|
|
|
|
|
# @json['profiles'][0]['controls'][0]['results'][0]['status']
|
|
|
|
|
failed_tests = []
|
|
|
|
|
@json["profiles"].each do |profile_struct|
|
|
|
|
|
profile_name = profile_struct["name"]
|
|
|
|
|
profile_struct["controls"].each do |control_struct|
|
|
|
|
|
control_name = control_struct["id"]
|
|
|
|
|
control_struct["results"].compact.each do |test_struct|
|
|
|
|
|
test_desc = test_struct["code_desc"]
|
|
|
|
|
if test_struct["status"] != "passed"
|
|
|
|
|
failed_tests << "#{profile_name}/#{control_name}/#{test_desc}"
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
_(failed_tests).must_be_empty
|
|
|
|
|
end
|
|
|
|
|
|
2019-11-06 22:43:44 +00:00
|
|
|
|
@inspec_mutex ||= Mutex.new
|
|
|
|
|
|
|
|
|
|
def self.inspec_mutex
|
|
|
|
|
@inspec_mutex
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def self.inspec_cache
|
|
|
|
|
@inspec_cache ||= {}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def inspec_cache
|
|
|
|
|
FunctionalHelper.inspec_cache
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def inspec_mutex
|
|
|
|
|
FunctionalHelper.inspec_mutex
|
|
|
|
|
end
|
2019-10-21 23:13:03 +00:00
|
|
|
|
|
2019-11-06 22:43:44 +00:00
|
|
|
|
def run_cmd(commandline, prefix = nil)
|
|
|
|
|
inspec_mutex.synchronize { # rubocop:disable Style/BlockDelimiters
|
|
|
|
|
inspec_cache[[commandline, prefix]] ||=
|
|
|
|
|
if is_windows?
|
|
|
|
|
invocation = "/windows/system32/cmd /C \"#{prefix} #{commandline}\""
|
|
|
|
|
# puts
|
|
|
|
|
# puts "CMD = #{invocation}"
|
|
|
|
|
result = CMD.run_command(invocation)
|
|
|
|
|
result.stdout.encode!(universal_newline: true)
|
|
|
|
|
result.stderr.encode!(universal_newline: true)
|
|
|
|
|
convert_windows_output(result.stdout)
|
|
|
|
|
# remove the CLIXML header trash in windows
|
|
|
|
|
result.stderr.gsub!("#< CLIXML\n", "")
|
|
|
|
|
result
|
|
|
|
|
else
|
|
|
|
|
invocation = "#{prefix} #{commandline}"
|
|
|
|
|
CMD.run_command(invocation)
|
|
|
|
|
end
|
|
|
|
|
}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def inspec(commandline, prefix = nil)
|
|
|
|
|
run_cmd "#{exec_inspec} #{commandline}", prefix
|
2016-03-25 00:31:19 +00:00
|
|
|
|
end
|
2017-06-12 12:01:26 +00:00
|
|
|
|
|
Plugins API v2: Loader, Base API, and Test Harness (#3278)
* Functional tests for userdir option
* Accepts --config-dir CLI option
* Actually loads a config file from the config dir, more cases to test
* Able to load config and verify contents from config-dir
* Functional tests to ensure precedence for config options
* Enable setting config dir via env var
* .inspec, not .inspec.d
* Begin converting PluginCtl to PluginLoader/Registry
* Able to load and partially validate the plugins.json file
* More work on the plugin loader
* Break the world, move next gen stuff to plugin/
* Be sure to require base cli in bundled plugins
* Move test file
* Revert changes to v1 plugin, so we can have a separate one
* Checkpoint commit
* Move v2 plugin work to v2 area
* Move plugins v1 code into an isolated directory
* rubocop fixes
* Rip out the stuff about a user-dir config file, just use a plugin file
* Two psuedocode test file
* Working base API, moock plugin type, and loader.
* Adjust load path to be more welcoming
* Silence circular depencency warning, which was breaking a unit test
* Linting
* Fix plugin type registry, add tests to cover
* Feedback from Jerry
Signed-off-by: Clinton Wolfe <clintoncwolfe@gmail.com>
2018-08-16 22:16:32 +00:00
|
|
|
|
def inspec_with_env(commandline, env = {})
|
2018-11-16 22:03:40 +00:00
|
|
|
|
inspec(commandline, assemble_env_prefix(env))
|
|
|
|
|
end
|
|
|
|
|
|
2019-01-10 01:14:42 +00:00
|
|
|
|
# This version allows additional options.
|
2018-11-16 22:03:40 +00:00
|
|
|
|
# @param String command_line Invocation, without the word 'inspec'
|
|
|
|
|
# @param Hash opts Additonal options, see below
|
|
|
|
|
# :env Hash A hash of environment vars to expose to the invocation.
|
|
|
|
|
# :prefix String A string to prefix to the invocation. Prefix + env + invocation is the order.
|
|
|
|
|
# :cwd String A directory to change to. Implemented as 'cd CWD && ' + prefix
|
|
|
|
|
# :lock Boolean Default false. If false, add `--no-create-lockfile`.
|
2019-10-21 23:13:03 +00:00
|
|
|
|
# :json Boolean Default false. If true, add `--reporter json` and parse the output, which is stored in @json.
|
2018-11-16 22:03:40 +00:00
|
|
|
|
# :tmpdir Boolean default true. If true, wrap execution in a Dir.tmpdir block. Use pre_run and post_run to trigger actions.
|
|
|
|
|
# :pre_run: Proc(tmp_dir_path) - optional setup block.
|
|
|
|
|
# tmp_dir will exist and be empty.
|
|
|
|
|
# :post_run: Proc(FuncTestRunResult, tmp_dir_path) - optional result capture block.
|
|
|
|
|
# tmp_dir will still exist (for a moment!)
|
2019-10-21 23:13:03 +00:00
|
|
|
|
# @return Train::Extrans::CommandResult
|
2019-01-12 00:42:46 +00:00
|
|
|
|
def run_inspec_process(command_line, opts = {})
|
2019-06-11 22:24:35 +00:00
|
|
|
|
raise "Do not use tmpdir and cwd in the same invocation" if opts[:cwd] && opts[:tmpdir]
|
2019-07-09 00:20:30 +00:00
|
|
|
|
|
2019-06-11 22:24:35 +00:00
|
|
|
|
prefix = opts[:cwd] ? "cd " + opts[:cwd] + " && " : ""
|
|
|
|
|
prefix += opts[:prefix] || ""
|
2018-11-16 22:03:40 +00:00
|
|
|
|
prefix += assemble_env_prefix(opts[:env])
|
2019-06-11 22:24:35 +00:00
|
|
|
|
command_line += " --reporter json " if opts[:json] && command_line =~ /\bexec\b/
|
|
|
|
|
command_line += " --no-create-lockfile " if (!opts[:lock]) && command_line =~ /\bexec\b/
|
2018-11-16 22:03:40 +00:00
|
|
|
|
|
|
|
|
|
run_result = nil
|
|
|
|
|
if opts[:tmpdir]
|
|
|
|
|
Dir.mktmpdir do |tmp_dir|
|
|
|
|
|
opts[:pre_run].call(tmp_dir) if opts[:pre_run]
|
|
|
|
|
# Do NOT Dir.chdir here - chdir / pwd is per-process, and we are in the
|
|
|
|
|
# test harness process, which will be multithreaded because we parallelize the tests.
|
|
|
|
|
# Instead, make the spawned process change dirs using a cd prefix.
|
2019-06-11 22:24:35 +00:00
|
|
|
|
prefix = "cd " + tmp_dir + " && " + prefix
|
2018-11-16 22:03:40 +00:00
|
|
|
|
run_result = inspec(command_line, prefix)
|
|
|
|
|
opts[:post_run].call(run_result, tmp_dir) if opts[:post_run]
|
|
|
|
|
end
|
2018-09-12 22:04:16 +00:00
|
|
|
|
else
|
2018-11-16 22:03:40 +00:00
|
|
|
|
run_result = inspec(command_line, prefix)
|
2018-09-12 22:04:16 +00:00
|
|
|
|
end
|
2018-11-16 22:03:40 +00:00
|
|
|
|
|
2019-04-24 15:37:43 +00:00
|
|
|
|
if opts[:ignore_rspec_deprecations]
|
|
|
|
|
# RSpec keeps issuing a deprecation count to stdout when .should is called explicitly
|
|
|
|
|
# See https://github.com/inspec/inspec/pull/3560
|
2019-06-11 22:24:35 +00:00
|
|
|
|
run_result.stdout.sub!("\n1 deprecation warning total\n", "")
|
2019-04-24 15:37:43 +00:00
|
|
|
|
end
|
|
|
|
|
|
2019-10-10 23:40:23 +00:00
|
|
|
|
if opts[:json] && !run_result.stdout.empty?
|
2018-11-16 22:03:40 +00:00
|
|
|
|
begin
|
2019-10-21 23:13:03 +00:00
|
|
|
|
@json = JSON.parse(run_result.stdout)
|
2018-11-16 22:03:40 +00:00
|
|
|
|
rescue JSON::ParserError => e
|
2019-10-11 06:20:12 +00:00
|
|
|
|
warn "JSON PARSE ERROR: %s" % [e.message]
|
|
|
|
|
warn "OUT: <<%s>>" % [run_result.stdout]
|
|
|
|
|
warn "ERR: <<%s>>" % [run_result.stderr]
|
|
|
|
|
warn "XIT: %p" % [run_result.exit_status]
|
2019-10-21 23:13:03 +00:00
|
|
|
|
@json = {}
|
|
|
|
|
@json_error = e
|
2018-11-16 22:03:40 +00:00
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
run_result
|
Plugins API v2: Loader, Base API, and Test Harness (#3278)
* Functional tests for userdir option
* Accepts --config-dir CLI option
* Actually loads a config file from the config dir, more cases to test
* Able to load config and verify contents from config-dir
* Functional tests to ensure precedence for config options
* Enable setting config dir via env var
* .inspec, not .inspec.d
* Begin converting PluginCtl to PluginLoader/Registry
* Able to load and partially validate the plugins.json file
* More work on the plugin loader
* Break the world, move next gen stuff to plugin/
* Be sure to require base cli in bundled plugins
* Move test file
* Revert changes to v1 plugin, so we can have a separate one
* Checkpoint commit
* Move v2 plugin work to v2 area
* Move plugins v1 code into an isolated directory
* rubocop fixes
* Rip out the stuff about a user-dir config file, just use a plugin file
* Two psuedocode test file
* Working base API, moock plugin type, and loader.
* Adjust load path to be more welcoming
* Silence circular depencency warning, which was breaking a unit test
* Linting
* Fix plugin type registry, add tests to cover
* Feedback from Jerry
Signed-off-by: Clinton Wolfe <clintoncwolfe@gmail.com>
2018-08-16 22:16:32 +00:00
|
|
|
|
end
|
|
|
|
|
|
2017-06-12 12:01:26 +00:00
|
|
|
|
# Copy all examples to a temporary directory for functional tests.
|
|
|
|
|
# You can provide an optional directory which will be handed to your
|
|
|
|
|
# test block with its absolute path. If nothing is provided you will
|
|
|
|
|
# get the path of the examples directory in the tmp environment.
|
|
|
|
|
#
|
|
|
|
|
# @param dir = nil [String] optional directory you want to test
|
|
|
|
|
# @param &block [Type] actual test block
|
|
|
|
|
def prepare_examples(dir = nil, &block)
|
|
|
|
|
Dir.mktmpdir do |tmpdir|
|
2019-03-18 15:04:02 +00:00
|
|
|
|
FileUtils.cp_r(examples_path, tmpdir)
|
|
|
|
|
bn = File.basename(examples_path)
|
2019-06-11 22:24:35 +00:00
|
|
|
|
yield(File.join(tmpdir, bn, dir.to_s))
|
2017-06-12 12:01:26 +00:00
|
|
|
|
end
|
|
|
|
|
end
|
2018-11-16 22:03:40 +00:00
|
|
|
|
|
2021-08-19 09:32:50 +00:00
|
|
|
|
def prepare_profiles(dir = nil, &block)
|
|
|
|
|
Dir.mktmpdir do |tmpdir|
|
|
|
|
|
FileUtils.cp_r(profile_path, tmpdir)
|
|
|
|
|
bn = File.basename(profile_path)
|
|
|
|
|
yield(File.join(tmpdir, bn, dir.to_s))
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2018-11-16 22:03:40 +00:00
|
|
|
|
private
|
2019-06-11 22:24:35 +00:00
|
|
|
|
|
2018-11-16 22:03:40 +00:00
|
|
|
|
def assemble_env_prefix(env = {})
|
|
|
|
|
if is_windows?
|
2019-06-11 22:24:35 +00:00
|
|
|
|
env_prefix = env.to_a.map { |assignment| "set #{assignment[0]}=#{assignment[1]}" }.join("&& ")
|
|
|
|
|
env_prefix += "&& " unless env_prefix.empty?
|
2018-11-16 22:03:40 +00:00
|
|
|
|
else
|
2019-06-11 22:24:35 +00:00
|
|
|
|
env_prefix = env.to_a.map { |assignment| "#{assignment[0]}=#{assignment[1]}" }.join(" ")
|
|
|
|
|
env_prefix += " "
|
2018-11-16 22:03:40 +00:00
|
|
|
|
end
|
|
|
|
|
env_prefix
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
#=========================================================================================#
|
|
|
|
|
# Plugin Support
|
|
|
|
|
#=========================================================================================#
|
|
|
|
|
module PluginFunctionalHelper
|
|
|
|
|
include FunctionalHelper
|
|
|
|
|
|
|
|
|
|
def run_inspec_with_plugin(command, opts)
|
|
|
|
|
pre = Proc.new do |tmp_dir|
|
|
|
|
|
content = JSON.generate(__make_plugin_file_data_structure_with_path(opts[:plugin_path]))
|
2019-06-11 22:24:35 +00:00
|
|
|
|
File.write(File.join(tmp_dir, "plugins.json"), content)
|
2018-11-16 22:03:40 +00:00
|
|
|
|
end
|
|
|
|
|
|
2019-10-11 00:23:06 +00:00
|
|
|
|
opts = {
|
2018-11-16 22:03:40 +00:00
|
|
|
|
pre_run: pre,
|
|
|
|
|
tmpdir: true,
|
|
|
|
|
json: true,
|
|
|
|
|
env: {
|
2019-06-11 22:24:35 +00:00
|
|
|
|
"INSPEC_CONFIG_DIR" => ".", # We're in tmpdir
|
|
|
|
|
},
|
2019-10-11 00:23:06 +00:00
|
|
|
|
}.merge(opts)
|
2018-11-16 22:03:40 +00:00
|
|
|
|
run_inspec_process(command, opts)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def __make_plugin_file_data_structure_with_path(path)
|
|
|
|
|
# TODO: dry this up, refs #3350
|
2019-06-11 22:24:35 +00:00
|
|
|
|
plugin_name = File.basename(path, ".rb")
|
2018-11-16 22:03:40 +00:00
|
|
|
|
data = __make_empty_plugin_file_data_structure
|
2019-06-11 22:24:35 +00:00
|
|
|
|
data["plugins"] << {
|
|
|
|
|
"name" => plugin_name,
|
|
|
|
|
"installation_type" => "path",
|
|
|
|
|
"installation_path" => path,
|
2018-11-16 22:03:40 +00:00
|
|
|
|
}
|
|
|
|
|
data
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def __make_empty_plugin_file_data_structure
|
|
|
|
|
# TODO: dry this up, refs #3350
|
|
|
|
|
{
|
2019-06-11 22:24:35 +00:00
|
|
|
|
"plugins_config_version" => "1.0.0",
|
|
|
|
|
"plugins" => [],
|
2018-11-16 22:03:40 +00:00
|
|
|
|
}
|
|
|
|
|
end
|
2016-03-25 00:31:19 +00:00
|
|
|
|
end
|