terraform-wrapper 0.0.2 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitlab-ci.yml +2 -0
- data/Gemfile +0 -4
- data/lib/terraform-wrapper.rb +36 -16
- data/lib/terraform-wrapper/common.rb +12 -23
- data/lib/terraform-wrapper/shared.rb +7 -1
- data/lib/terraform-wrapper/shared/auths.rb +9 -0
- data/lib/terraform-wrapper/shared/auths/azure.rb +179 -0
- data/lib/terraform-wrapper/shared/auths/common.rb +95 -0
- data/lib/terraform-wrapper/shared/backends/aws.rb +71 -33
- data/lib/terraform-wrapper/shared/backends/azure.rb +38 -39
- data/lib/terraform-wrapper/shared/backends/common.rb +18 -14
- data/lib/terraform-wrapper/shared/backends/local.rb +20 -18
- data/lib/terraform-wrapper/shared/binary.rb +16 -5
- data/lib/terraform-wrapper/shared/code.rb +15 -4
- data/lib/terraform-wrapper/shared/config.rb +61 -31
- data/lib/terraform-wrapper/shared/latest.rb +11 -7
- data/lib/terraform-wrapper/shared/logger.rb +80 -0
- data/lib/terraform-wrapper/shared/logging.rb +77 -0
- data/lib/terraform-wrapper/shared/runner.rb +79 -21
- data/lib/terraform-wrapper/shared/variables.rb +66 -0
- data/lib/terraform-wrapper/tasks.rb +6 -0
- data/lib/terraform-wrapper/tasks/apply.rb +16 -18
- data/lib/terraform-wrapper/tasks/binary.rb +26 -23
- data/lib/terraform-wrapper/tasks/clean.rb +15 -15
- data/lib/terraform-wrapper/tasks/destroy.rb +16 -18
- data/lib/terraform-wrapper/tasks/import.rb +66 -0
- data/lib/terraform-wrapper/tasks/init.rb +16 -18
- data/lib/terraform-wrapper/tasks/plan.rb +16 -18
- data/lib/terraform-wrapper/tasks/plandestroy.rb +16 -18
- data/lib/terraform-wrapper/tasks/upgrade.rb +58 -0
- data/lib/terraform-wrapper/tasks/validate.rb +7 -7
- data/lib/terraform-wrapper/version.rb +1 -1
- data/terraform-wrapper.gemspec +3 -0
- metadata +39 -4
- data/lib/terraform-wrapper/shared/identifiers.rb +0 -70
@@ -14,6 +14,10 @@ module TerraformWrapper
|
|
14
14
|
|
15
15
|
class Config
|
16
16
|
|
17
|
+
###############################################################################
|
18
|
+
|
19
|
+
include TerraformWrapper::Shared::Logging
|
20
|
+
|
17
21
|
###############################################################################
|
18
22
|
|
19
23
|
@@config_exts = [ "", ".yaml", ".yml" ]
|
@@ -25,53 +29,79 @@ module TerraformWrapper
|
|
25
29
|
|
26
30
|
###############################################################################
|
27
31
|
|
32
|
+
attr_reader :auths
|
28
33
|
attr_reader :backend
|
29
34
|
attr_reader :base
|
30
35
|
attr_reader :code
|
31
36
|
attr_reader :name
|
32
37
|
attr_reader :path
|
33
|
-
attr_reader :overrides
|
34
38
|
attr_reader :service
|
35
39
|
attr_reader :variable_files
|
40
|
+
attr_reader :variables
|
36
41
|
|
37
42
|
###############################################################################
|
38
43
|
|
39
|
-
def initialize(
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
44
|
+
def initialize(code:, options:)
|
45
|
+
logger.fatal("Configuration base path must be a String!") unless options["base"].kind_of?(String)
|
46
|
+
logger.fatal("Configuration base path must not be blank!") if options["base"].strip.empty?
|
47
|
+
|
48
|
+
@base = options["base"]
|
49
|
+
|
50
|
+
logger.fatal("Configuration service name must be a String!") unless options["service"].kind_of?(String)
|
51
|
+
logger.fatal("Configuration service name must not be blank!") if options["service"].strip.empty?
|
52
|
+
|
53
|
+
@service = options["service"]
|
54
|
+
|
55
|
+
logger.fatal("Configuration name must be a String!") unless options["name"].kind_of?(String)
|
56
|
+
logger.fatal("Configuration name must not be blank!") if options["name"].strip.empty?
|
57
|
+
|
58
|
+
@name = options["name"]
|
59
|
+
|
60
|
+
logger.fatal("Configuration authenticator for Azure enabled must be a Boolean!") unless [ true, false ].include?(options["auth-azure"])
|
45
61
|
|
46
|
-
|
47
|
-
@code = code
|
48
|
-
@name = name
|
49
|
-
@overrides = overrides
|
50
|
-
@service = service
|
62
|
+
auth_azure = options["auth-azure"]
|
51
63
|
|
64
|
+
logger.fatal("Configuration authenticator for Azure options must be a Hash!") unless options["auth-azure-options"].kind_of?(Hash)
|
65
|
+
|
66
|
+
auth_azure_options = options["auth-azure-options"]
|
67
|
+
|
68
|
+
logger.fatal("Configuration backend name must be a String!") unless options["backend"].kind_of?(String)
|
69
|
+
logger.fatal("Configuration backend name must not be blank!") if options["backend"].strip.empty?
|
70
|
+
|
71
|
+
backend = options["backend"]
|
72
|
+
|
73
|
+
logger.fatal("Configuration backend options must be a Hash!") unless options["backend-options"].kind_of?(Hash)
|
74
|
+
|
75
|
+
backend_options = options["backend-options"]
|
76
|
+
|
77
|
+
@code = code
|
52
78
|
@path = find
|
53
79
|
|
54
80
|
yaml = YAML.load(File.read(@path))
|
55
81
|
|
56
|
-
|
82
|
+
logger.fatal("Invalid YAML in configuration file: #{@path}") unless yaml.kind_of?(Hash)
|
57
83
|
|
58
|
-
|
59
|
-
|
60
|
-
|
84
|
+
if yaml.key?("variables") then
|
85
|
+
logger.fatal("Key 'variables' is not a hash in configuration file: #{@path}") unless yaml["variables"].kind_of?(Hash)
|
86
|
+
@variables = TerraformWrapper::Shared::Variables.new(values: yaml["variables"])
|
87
|
+
else
|
88
|
+
@variables = TerraformWrapper::Shared::Variables.new()
|
89
|
+
end
|
61
90
|
|
62
|
-
|
91
|
+
@variable_files = yaml.key?("terraform") ? validate(variable_files: yaml["terraform"]) : Array.new
|
92
|
+
|
93
|
+
@auths = Array.new
|
94
|
+
@auths.append(TerraformWrapper::Shared::Auths::Azure.new(code: @code, config: @name, options: auth_azure_options, service: @service, variables: @variables)) if auth_azure
|
63
95
|
|
64
96
|
if backend == "local" then
|
65
|
-
@backend = TerraformWrapper::Shared::Backends::Local.new(
|
97
|
+
@backend = TerraformWrapper::Shared::Backends::Local.new(code: @code, config: @name, options: backend_options, service: @service, variables: @variables)
|
66
98
|
elsif backend == "aws" then
|
67
|
-
@backend = TerraformWrapper::Shared::Backends::AWS.new(
|
99
|
+
@backend = TerraformWrapper::Shared::Backends::AWS.new(code: @code, config: @name, options: backend_options, service: @service, variables: @variables)
|
68
100
|
elsif backend == "azure" then
|
69
|
-
@backend = TerraformWrapper::Shared::Backends::Azure.new(
|
101
|
+
@backend = TerraformWrapper::Shared::Backends::Azure.new(code: @code, config: @name, options: backend_options, service: @service, variables: @variables)
|
70
102
|
else
|
71
|
-
|
103
|
+
logger.fatal("Backend: #{backend} is not valid!")
|
72
104
|
end
|
73
|
-
|
74
|
-
@variable_files = yaml.key?("tfvars") ? tfvars(tfvars: yaml["tfvars"]) : Array.new
|
75
105
|
end
|
76
106
|
|
77
107
|
###############################################################################
|
@@ -86,24 +116,24 @@ module TerraformWrapper
|
|
86
116
|
return path if File.file?(path)
|
87
117
|
end
|
88
118
|
|
89
|
-
|
119
|
+
logger.fatal("Terraform configuration name: #{@name} not found in location: #{@base}!")
|
90
120
|
end
|
91
121
|
|
92
122
|
###############################################################################
|
93
123
|
|
94
|
-
def
|
95
|
-
|
124
|
+
def validate(variable_files:)
|
125
|
+
logger.fatal("Optional key 'variable_files' must be a list of strings!") unless variable_files.kind_of?(Array)
|
96
126
|
|
97
127
|
result = Array.new
|
98
128
|
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
path = File.join(@base, @@variable_files_name,
|
129
|
+
variable_files.each do |variable_file|
|
130
|
+
logger.fatal("All elements of 'variable_files' must be strings!") unless variable_file.kind_of?(String)
|
131
|
+
logger.fatal("All elements of 'variable_files' must not be blank!") if variable_file.strip.empty?
|
132
|
+
path = File.join(@base, @@variable_files_name, variable_file.strip + @@variable_files_ext)
|
103
133
|
if File.file?(path) then
|
104
134
|
result.append(path)
|
105
135
|
else
|
106
|
-
|
136
|
+
logger.fatal("Terraform variables file: #{variable_file}, path: #{path} does not exist!")
|
107
137
|
end
|
108
138
|
end
|
109
139
|
|
@@ -21,6 +21,10 @@ module TerraformWrapper
|
|
21
21
|
|
22
22
|
include Singleton
|
23
23
|
|
24
|
+
###############################################################################
|
25
|
+
|
26
|
+
include TerraformWrapper::Shared::Logging
|
27
|
+
|
24
28
|
###############################################################################
|
25
29
|
|
26
30
|
@version
|
@@ -40,22 +44,22 @@ module TerraformWrapper
|
|
40
44
|
###############################################################################
|
41
45
|
|
42
46
|
def refresh
|
43
|
-
|
47
|
+
logger.info("Finding latest available Terraform release...")
|
44
48
|
|
45
49
|
response = Net::HTTP.get_response(URI("https://checkpoint-api.hashicorp.com/v1/check/terraform"))
|
46
50
|
|
47
|
-
|
48
|
-
|
49
|
-
|
51
|
+
logger.fatal("Hashicorp Checkpoint did not return status 200 for latest version check!") if response.code != "200"
|
52
|
+
logger.fatal("Response body from Hashicorp Checkpoint is not permitted!") if not response.class.body_permitted?
|
53
|
+
logger.fatal("Response body from Hashicorp Checkpoint is empty!") if response.body.nil?
|
50
54
|
|
51
55
|
body = JSON.parse(response.body)
|
52
56
|
|
53
|
-
|
54
|
-
|
57
|
+
logger.fatal("Hashicorp Checkpoint JSON response did not include latest available Terraform version!") if not body.key?("current_version")
|
58
|
+
logger.fatal("Hashicorp Checkpoint indicated latest available version of Terraform is blank!") if body["current_version"].empty?
|
55
59
|
|
56
60
|
version = body["current_version"]
|
57
61
|
|
58
|
-
|
62
|
+
logger.success("Latest available Terraform release found: #{version}")
|
59
63
|
|
60
64
|
return version
|
61
65
|
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
###############################################################################
|
2
|
+
|
3
|
+
require 'logger'
|
4
|
+
|
5
|
+
###############################################################################
|
6
|
+
|
7
|
+
module TerraformWrapper
|
8
|
+
|
9
|
+
###############################################################################
|
10
|
+
|
11
|
+
module Shared
|
12
|
+
|
13
|
+
###############################################################################
|
14
|
+
|
15
|
+
class Logger < ::Logger
|
16
|
+
|
17
|
+
###############################################################################
|
18
|
+
|
19
|
+
@colour = false
|
20
|
+
|
21
|
+
###############################################################################
|
22
|
+
|
23
|
+
def colour()
|
24
|
+
@colour
|
25
|
+
end
|
26
|
+
|
27
|
+
###############################################################################
|
28
|
+
|
29
|
+
def colour=(enabled)
|
30
|
+
@colour = [ true, false ].include?(enabled) ? enabled : false
|
31
|
+
end
|
32
|
+
|
33
|
+
###############################################################################
|
34
|
+
|
35
|
+
def success(message)
|
36
|
+
info(format(colour: 32, message: message))
|
37
|
+
end
|
38
|
+
|
39
|
+
###############################################################################
|
40
|
+
|
41
|
+
def warn(message)
|
42
|
+
super(format(colour: 33, message: message))
|
43
|
+
end
|
44
|
+
|
45
|
+
###############################################################################
|
46
|
+
|
47
|
+
def error(message)
|
48
|
+
super(format(colour: 31, message: message))
|
49
|
+
end
|
50
|
+
|
51
|
+
###############################################################################
|
52
|
+
|
53
|
+
def fatal(message)
|
54
|
+
super(format(colour: 31, message: message))
|
55
|
+
exit(1)
|
56
|
+
end
|
57
|
+
|
58
|
+
###############################################################################
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
###############################################################################
|
63
|
+
|
64
|
+
def format(colour: 32, message:)
|
65
|
+
return @colour ? "\e[" + colour.to_s + "m" + message + "\e[0m" : message
|
66
|
+
end
|
67
|
+
|
68
|
+
###############################################################################
|
69
|
+
|
70
|
+
end
|
71
|
+
|
72
|
+
###############################################################################
|
73
|
+
|
74
|
+
end
|
75
|
+
|
76
|
+
###############################################################################
|
77
|
+
|
78
|
+
end
|
79
|
+
|
80
|
+
###############################################################################
|
@@ -0,0 +1,77 @@
|
|
1
|
+
###############################################################################
|
2
|
+
|
3
|
+
require 'logger'
|
4
|
+
|
5
|
+
###############################################################################
|
6
|
+
|
7
|
+
module TerraformWrapper
|
8
|
+
|
9
|
+
###############################################################################
|
10
|
+
|
11
|
+
module Shared
|
12
|
+
|
13
|
+
###############################################################################
|
14
|
+
|
15
|
+
module Logging
|
16
|
+
|
17
|
+
###############################################################################
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
###############################################################################
|
22
|
+
|
23
|
+
def logger
|
24
|
+
@logger ||= Logging.logger_for(self.class.name)
|
25
|
+
end
|
26
|
+
|
27
|
+
###############################################################################
|
28
|
+
|
29
|
+
@loggers = {}
|
30
|
+
|
31
|
+
###############################################################################
|
32
|
+
|
33
|
+
class << self
|
34
|
+
|
35
|
+
###############################################################################
|
36
|
+
|
37
|
+
def logger_for(classname)
|
38
|
+
@loggers[classname] ||= configure_logger_for(classname)
|
39
|
+
end
|
40
|
+
|
41
|
+
###############################################################################
|
42
|
+
|
43
|
+
def configure_logger_for(classname)
|
44
|
+
colour = ENV["TERRAFORM_WRAPPER_LOG_COLOUR"] || "true"
|
45
|
+
level = ENV["TERRAFORM_WRAPPER_LOG_LEVEL"] || "INFO"
|
46
|
+
|
47
|
+
logger = ::TerraformWrapper::Shared::Logger.new(STDOUT)
|
48
|
+
|
49
|
+
logger.colour = colour.downcase == "true"
|
50
|
+
logger.level = level.upcase
|
51
|
+
logger.progname = classname
|
52
|
+
|
53
|
+
logger.formatter = proc do |severity, datetime, progname, msg|
|
54
|
+
sevId = severity.chars.first.upcase
|
55
|
+
"[#{sevId}] [#{progname}] #{msg}\n"
|
56
|
+
end
|
57
|
+
|
58
|
+
logger
|
59
|
+
end
|
60
|
+
|
61
|
+
###############################################################################
|
62
|
+
|
63
|
+
end
|
64
|
+
|
65
|
+
###############################################################################
|
66
|
+
|
67
|
+
end
|
68
|
+
|
69
|
+
###############################################################################
|
70
|
+
|
71
|
+
end
|
72
|
+
|
73
|
+
###############################################################################
|
74
|
+
|
75
|
+
end
|
76
|
+
|
77
|
+
###############################################################################
|
@@ -10,6 +10,10 @@ module TerraformWrapper
|
|
10
10
|
|
11
11
|
class Runner
|
12
12
|
|
13
|
+
###############################################################################
|
14
|
+
|
15
|
+
include TerraformWrapper::Shared::Logging
|
16
|
+
|
13
17
|
###############################################################################
|
14
18
|
|
15
19
|
attr_reader :binary
|
@@ -30,75 +34,111 @@ module TerraformWrapper
|
|
30
34
|
|
31
35
|
###############################################################################
|
32
36
|
|
33
|
-
def download
|
34
|
-
parameters =
|
37
|
+
def download(upgrade: false)
|
38
|
+
parameters = Array.new
|
39
|
+
parameters.append("-backend=false")
|
40
|
+
parameters.append("-upgrade") if upgrade
|
41
|
+
|
35
42
|
@downloaded = run(action: "init", parameters: parameters)
|
36
|
-
|
43
|
+
logger.fatal("Failed to download Terraform modules.") unless @downloaded
|
44
|
+
end
|
45
|
+
|
46
|
+
###############################################################################
|
47
|
+
|
48
|
+
def import(address: nil, id: nil)
|
49
|
+
logger.fatal("Cannot Terraform import before initialising backend!") unless initialised
|
50
|
+
|
51
|
+
logger.fatal("Terraform state address for import must be a String!") unless address.kind_of?(String)
|
52
|
+
logger.fatal("Terraform state address for import must be a String!") unless address.kind_of?(String)
|
53
|
+
logger.fatal("Terraform state address for import must not be blank!") if address.strip.empty?
|
54
|
+
|
55
|
+
logger.fatal("Identification for infrastructure to import must be a String!") unless id.kind_of?(String)
|
56
|
+
logger.fatal("Identification for infrastructure to import must not be blank!") if id.strip.empty?
|
57
|
+
|
58
|
+
parameters = Array.new
|
59
|
+
parameters.concat(variable_files)
|
60
|
+
parameters.concat(variable_strings)
|
61
|
+
|
62
|
+
parameters.append("'#{address}'")
|
63
|
+
parameters.append("'#{id}'")
|
64
|
+
|
65
|
+
logger.fatal("Terraform import failed!") unless run(action: "import", parameters: parameters)
|
37
66
|
end
|
38
67
|
|
39
68
|
###############################################################################
|
40
69
|
|
41
70
|
def init(config:)
|
42
|
-
parameters =
|
71
|
+
parameters = Array.new
|
72
|
+
parameters.append("-reconfigure")
|
73
|
+
|
43
74
|
config.backend.hash.each do |key, value|
|
44
75
|
parameters.append("-backend-config=\"#{key}=#{value}\"")
|
45
76
|
end
|
46
77
|
|
78
|
+
config.auths.map(&:auth)
|
79
|
+
|
47
80
|
@config = config
|
48
81
|
@initialised = run(action: "init", parameters: parameters)
|
49
|
-
|
82
|
+
logger.fatal("Failed to initialise Terraform with backend.") unless @initialised
|
50
83
|
end
|
51
84
|
|
52
85
|
###############################################################################
|
53
86
|
|
54
87
|
def plan(destroy: false, file: nil)
|
55
|
-
|
88
|
+
logger.fatal("Cannot Terraform plan before initialising backend!") unless initialised
|
56
89
|
|
57
|
-
parameters =
|
90
|
+
parameters = Array.new
|
91
|
+
parameters.concat(variable_files)
|
92
|
+
parameters.concat(variable_strings)
|
58
93
|
|
59
94
|
if not file.nil? and file.kind_of?(String) and not file.strip.empty? then
|
60
|
-
|
95
|
+
logger.fatal("Failed to create plan directory: #{directory}") unless ::TerraformWrapper.create_directory(directory: File.dirname(file), purpose: "plan")
|
61
96
|
parameters.append("-out=\"#{file}\"")
|
62
97
|
end
|
63
98
|
|
64
99
|
parameters.append("-destroy") if destroy
|
65
100
|
|
66
|
-
|
101
|
+
logger.fatal("Terraform plan failed!") unless run(action: "plan", parameters: parameters)
|
67
102
|
end
|
68
103
|
|
69
104
|
###############################################################################
|
70
105
|
|
71
106
|
def apply(file: nil)
|
72
|
-
|
107
|
+
logger.fatal("Cannot Terraform apply before initialising backend!") unless initialised
|
73
108
|
|
74
|
-
parameters =
|
109
|
+
parameters = Array.new
|
110
|
+
parameters.concat(variable_files)
|
111
|
+
parameters.concat(variable_strings)
|
112
|
+
parameters.append("-auto-approve")
|
75
113
|
|
76
114
|
if not file.nil? and file.kind_of?(String) and not file.strip.empty? then
|
77
|
-
|
115
|
+
logger.fatal("Plan file: #{file} does not exist!") unless File.file?(file)
|
78
116
|
parameters.append("\"#{file}\"")
|
79
117
|
else
|
80
118
|
parameters.concat(variable_files)
|
81
119
|
end
|
82
120
|
|
83
|
-
|
121
|
+
logger.fatal("Terraform apply failed!") unless run(action: "apply", parameters: parameters)
|
84
122
|
end
|
85
123
|
|
86
124
|
###############################################################################
|
87
125
|
|
88
126
|
def destroy
|
89
|
-
|
127
|
+
logger.fatal("Cannot Terraform destroy before initialising backend!") unless initialised
|
90
128
|
|
91
|
-
parameters =
|
129
|
+
parameters = Array.new
|
92
130
|
parameters.concat(variable_files)
|
131
|
+
parameters.concat(variable_strings)
|
132
|
+
parameters.append("-auto-approve")
|
93
133
|
|
94
|
-
|
134
|
+
logger.fatal("Terraform destroy failed!") unless run(action: "destroy", parameters: parameters)
|
95
135
|
end
|
96
136
|
|
97
137
|
###############################################################################
|
98
138
|
|
99
139
|
def validate
|
100
|
-
|
101
|
-
|
140
|
+
logger.fatal("Cannot Terraform validate before downloading modules!") unless downloaded
|
141
|
+
logger.fatal("Terraform validation failed!") unless run(action: "validate")
|
102
142
|
end
|
103
143
|
|
104
144
|
###############################################################################
|
@@ -108,7 +148,7 @@ module TerraformWrapper
|
|
108
148
|
###############################################################################
|
109
149
|
|
110
150
|
def variable_files
|
111
|
-
|
151
|
+
logger.fatal("Cannot generate variable files until Terraform has been initialised!") unless @initialised
|
112
152
|
|
113
153
|
result = Array.new
|
114
154
|
|
@@ -119,6 +159,24 @@ module TerraformWrapper
|
|
119
159
|
return result
|
120
160
|
end
|
121
161
|
|
162
|
+
###############################################################################
|
163
|
+
|
164
|
+
def variable_strings
|
165
|
+
logger.fatal("Cannot generate variable strings until Terraform has been initialised!") unless @initialised
|
166
|
+
|
167
|
+
result = Array.new
|
168
|
+
|
169
|
+
result.append("-var=\"component=#{@code.name}\"")
|
170
|
+
result.append("-var=\"config=#{@config.name}\"")
|
171
|
+
result.append("-var=\"service=#{@config.service}\"")
|
172
|
+
|
173
|
+
@config.variables.values.each do |key, value|
|
174
|
+
result.append("-var=\"#{key.to_s}=#{value}\"")
|
175
|
+
end
|
176
|
+
|
177
|
+
return result
|
178
|
+
end
|
179
|
+
|
122
180
|
###############################################################################
|
123
181
|
|
124
182
|
def run(action:, parameters: Array.new)
|
@@ -128,14 +186,14 @@ module TerraformWrapper
|
|
128
186
|
|
129
187
|
cmdline = [ "\"#{@binary.path}\"", action ].concat(parameters).join(" ")
|
130
188
|
|
131
|
-
|
189
|
+
logger.info("Starting Terraform, action: #{action}")
|
132
190
|
|
133
191
|
puts("\n" + ('#' * 80) + "\n\n")
|
134
192
|
|
135
193
|
Dir.chdir(@code.path)
|
136
194
|
result = system(cmdline) || false
|
137
195
|
|
138
|
-
puts("\n")
|
196
|
+
puts("\n" + ('#' * 80) + "\n\n")
|
139
197
|
|
140
198
|
return result
|
141
199
|
end
|