sfn 0.0.1 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +107 -0
- data/LICENSE +13 -0
- data/README.md +142 -61
- data/bin/sfn +43 -0
- data/lib/chef/knife/knife_plugin_seed.rb +117 -0
- data/lib/sfn.rb +17 -0
- data/lib/sfn/cache.rb +385 -0
- data/lib/sfn/command.rb +45 -0
- data/lib/sfn/command/create.rb +87 -0
- data/lib/sfn/command/describe.rb +87 -0
- data/lib/sfn/command/destroy.rb +74 -0
- data/lib/sfn/command/events.rb +98 -0
- data/lib/sfn/command/export.rb +103 -0
- data/lib/sfn/command/import.rb +117 -0
- data/lib/sfn/command/inspect.rb +160 -0
- data/lib/sfn/command/list.rb +59 -0
- data/lib/sfn/command/promote.rb +17 -0
- data/lib/sfn/command/update.rb +95 -0
- data/lib/sfn/command/validate.rb +34 -0
- data/lib/sfn/command_module.rb +9 -0
- data/lib/sfn/command_module/base.rb +150 -0
- data/lib/sfn/command_module/stack.rb +166 -0
- data/lib/sfn/command_module/template.rb +147 -0
- data/lib/sfn/config.rb +106 -0
- data/lib/sfn/config/create.rb +35 -0
- data/lib/sfn/config/describe.rb +19 -0
- data/lib/sfn/config/destroy.rb +9 -0
- data/lib/sfn/config/events.rb +25 -0
- data/lib/sfn/config/export.rb +29 -0
- data/lib/sfn/config/import.rb +24 -0
- data/lib/sfn/config/inspect.rb +37 -0
- data/lib/sfn/config/list.rb +25 -0
- data/lib/sfn/config/promote.rb +23 -0
- data/lib/sfn/config/update.rb +20 -0
- data/lib/sfn/config/validate.rb +49 -0
- data/lib/sfn/monkey_patch.rb +8 -0
- data/lib/sfn/monkey_patch/stack.rb +200 -0
- data/lib/sfn/provider.rb +224 -0
- data/lib/sfn/utils.rb +23 -0
- data/lib/sfn/utils/debug.rb +31 -0
- data/lib/sfn/utils/json.rb +37 -0
- data/lib/sfn/utils/object_storage.rb +28 -0
- data/lib/sfn/utils/output.rb +79 -0
- data/lib/sfn/utils/path_selector.rb +99 -0
- data/lib/sfn/utils/ssher.rb +29 -0
- data/lib/sfn/utils/stack_exporter.rb +275 -0
- data/lib/sfn/utils/stack_parameter_scrubber.rb +37 -0
- data/lib/sfn/utils/stack_parameter_validator.rb +124 -0
- data/lib/sfn/version.rb +4 -0
- data/sfn.gemspec +19 -0
- metadata +110 -4
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
require 'sparkle_formation'
|
3
|
+
require 'sfn'
|
4
|
+
|
5
|
+
module Sfn
|
6
|
+
class Command
|
7
|
+
# Validate command
|
8
|
+
class Validate < Command
|
9
|
+
|
10
|
+
include Sfn::CommandModule::Base
|
11
|
+
include Sfn::CommandModule::Template
|
12
|
+
|
13
|
+
def execute!
|
14
|
+
file = load_template_file
|
15
|
+
file.delete('sfn_nested_stack')
|
16
|
+
ui.info "#{ui.color("Template Validation (#{provider.connection.provider}): ", :bold)} #{config[:file].sub(Dir.pwd, '').sub(%r{^/}, '')}"
|
17
|
+
file = Sfn::Utils::StackParameterScrubber.scrub!(file)
|
18
|
+
file = translate_template(file)
|
19
|
+
begin
|
20
|
+
result = provider.connection.stacks.build(
|
21
|
+
:name => 'validation-stack',
|
22
|
+
:template => file
|
23
|
+
).validate
|
24
|
+
ui.info ui.color(' -> VALID', :bold, :green)
|
25
|
+
rescue => e
|
26
|
+
ui.info ui.color(' -> INVALID', :bold, :red)
|
27
|
+
ui.fatal e.message
|
28
|
+
failed = true
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,150 @@
|
|
1
|
+
require 'sfn'
|
2
|
+
|
3
|
+
module Sfn
|
4
|
+
module CommandModule
|
5
|
+
# Base module for CLIs
|
6
|
+
module Base
|
7
|
+
|
8
|
+
# Instance methods for cloudformation command classes
|
9
|
+
module InstanceMethods
|
10
|
+
|
11
|
+
# @return [KnifeCloudformation::Provider]
|
12
|
+
def provider
|
13
|
+
memoize(:provider, :direct) do
|
14
|
+
Sfn::Provider.new(
|
15
|
+
:miasma => config[:credentials],
|
16
|
+
:async => false,
|
17
|
+
:fetch => false
|
18
|
+
)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Write exception information if debug is enabled
|
23
|
+
#
|
24
|
+
# @param e [Exception]
|
25
|
+
# @param args [String] extra strings to output
|
26
|
+
def _debug(e, *args)
|
27
|
+
if(config[:verbose])
|
28
|
+
ui.fatal "Exception information: #{e.class}: #{e.message}"
|
29
|
+
if(ENV['DEBUG'])
|
30
|
+
puts "#{e.backtrace.join("\n")}\n"
|
31
|
+
if(e.is_a?(Miasma::Error::ApiError))
|
32
|
+
ui.fatal "Response body: #{e.response.body.to_s.inspect}"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
args.each do |string|
|
36
|
+
ui.fatal string
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Format snake cased key to title
|
42
|
+
#
|
43
|
+
# @param string [String, Symbol]
|
44
|
+
# @return [String
|
45
|
+
def as_title(string)
|
46
|
+
string.to_s.split('_').map(&:capitalize).join(' ')
|
47
|
+
end
|
48
|
+
|
49
|
+
# Get stack
|
50
|
+
#
|
51
|
+
# @param name [String] name of stack
|
52
|
+
# @return [Miasma::Models::Orchestration::Stack]
|
53
|
+
def stack(name)
|
54
|
+
provider.stacks.get(name)
|
55
|
+
end
|
56
|
+
|
57
|
+
# @return [Array<String>] attributes to display
|
58
|
+
def allowed_attributes
|
59
|
+
opts.fetch(:attributes, config.fetch(:attributes, default_attributes))
|
60
|
+
end
|
61
|
+
|
62
|
+
# @return [Array<String>] default attributes to display
|
63
|
+
def default_attributes
|
64
|
+
%w(timestamp stack_name id)
|
65
|
+
end
|
66
|
+
|
67
|
+
# Check if attribute is allowed for display
|
68
|
+
#
|
69
|
+
# @param attr [String]
|
70
|
+
# @return [TrueClass, FalseClass]
|
71
|
+
def attribute_allowed?(attr)
|
72
|
+
opts.fetch(:all_attributes, config[:all_attributes], allowed_attributes.include?(attr))
|
73
|
+
end
|
74
|
+
|
75
|
+
# Poll events on stack
|
76
|
+
#
|
77
|
+
# @param name [String] name of stack
|
78
|
+
def poll_stack(name)
|
79
|
+
provider.connection.stacks.reload
|
80
|
+
retry_attempts = 0
|
81
|
+
begin
|
82
|
+
events = Sfn::Command::Events.new({:poll => true}, [name]).execute!
|
83
|
+
rescue => e
|
84
|
+
if(retry_attempts < config.fetch(:max_poll_retries, 5).to_i)
|
85
|
+
retry_attempts += 1
|
86
|
+
warn "Unexpected error encountered (#{e.class}: #{e}) Retrying [retry count: #{retry_attempts}]"
|
87
|
+
sleep(1)
|
88
|
+
retry
|
89
|
+
else
|
90
|
+
raise
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Wrapper for information retrieval. Provides consistent error
|
96
|
+
# message for failures
|
97
|
+
#
|
98
|
+
# @param stack [String] stack name
|
99
|
+
# @param message [String] failure message
|
100
|
+
# @yield block to wrap error handling
|
101
|
+
# @return [Object] result of yield
|
102
|
+
def get_things(stack=nil, message=nil)
|
103
|
+
begin
|
104
|
+
yield
|
105
|
+
rescue => e
|
106
|
+
ui.fatal "#{message || 'Failed to retrieve information'}#{" for requested stack: #{stack}" if stack}"
|
107
|
+
ui.fatal "Reason: #{e}"
|
108
|
+
_debug(e)
|
109
|
+
exit 1
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# Simple compat proxy method
|
114
|
+
#
|
115
|
+
# @return [Array<String>]
|
116
|
+
def name_args
|
117
|
+
arguments
|
118
|
+
end
|
119
|
+
|
120
|
+
# Fetches value from local configuration (#opts) and falls
|
121
|
+
# back to global configuration (#options)
|
122
|
+
#
|
123
|
+
# @return [Object]
|
124
|
+
def config
|
125
|
+
memoize(:config) do
|
126
|
+
options.deep_merge(opts)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
end
|
131
|
+
|
132
|
+
class << self
|
133
|
+
def included(klass)
|
134
|
+
klass.instance_eval do
|
135
|
+
|
136
|
+
include Sfn::CommandModule::Base::InstanceMethods
|
137
|
+
include Sfn::Utils::JSON
|
138
|
+
include Sfn::Utils::Output
|
139
|
+
include Bogo::AnimalStrings
|
140
|
+
include Bogo::Memoization
|
141
|
+
include Bogo::Constants
|
142
|
+
|
143
|
+
end
|
144
|
+
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
end
|
150
|
+
end
|
@@ -0,0 +1,166 @@
|
|
1
|
+
require 'sfn'
|
2
|
+
require 'sparkle_formation'
|
3
|
+
|
4
|
+
module Sfn
|
5
|
+
module CommandModule
|
6
|
+
# Stack handling helper methods
|
7
|
+
module Stack
|
8
|
+
|
9
|
+
module InstanceMethods
|
10
|
+
|
11
|
+
# unpacked stack name joiner/identifier
|
12
|
+
UNPACK_NAME_JOINER = '-sfn-'
|
13
|
+
# maximum number of attempts to get valid parameter value
|
14
|
+
MAX_PARAMETER_ATTEMPTS = 5
|
15
|
+
|
16
|
+
# Apply any defined remote stacks
|
17
|
+
#
|
18
|
+
# @param stack [Miasma::Models::Orchestration::Stack]
|
19
|
+
# @return [Miasma::Models::Orchestration::Stack]
|
20
|
+
def apply_stacks!(stack)
|
21
|
+
remote_stacks = config.fetch(:apply_stacks, [])
|
22
|
+
remote_stacks.each do |stack_name|
|
23
|
+
remote_stack = provider.connection.stacks.get(stack_name)
|
24
|
+
if(remote_stack)
|
25
|
+
apply_nested_stacks!(remote_stack, stack)
|
26
|
+
stack.apply_stack(remote_stack)
|
27
|
+
else
|
28
|
+
apply_unpacked_stack!(stack_name, stack)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
stack
|
32
|
+
end
|
33
|
+
|
34
|
+
# Detect nested stacks and apply
|
35
|
+
#
|
36
|
+
# @param remote_stack [Miasma::Models::Orchestration::Stack] stack to inspect for nested stacks
|
37
|
+
# @param stack [Miasma::Models::Orchestration::Stack] current stack
|
38
|
+
# @return [Miasma::Models::Orchestration::Stack]
|
39
|
+
def apply_nested_stacks(remote_stack, stack)
|
40
|
+
remote_stack.resources.all.each do |resource|
|
41
|
+
if(resource.type == 'AWS::CloudFormation::Stack')
|
42
|
+
nested_stack = resource.expand
|
43
|
+
apply_nested_stacks!(nested_stack, stack)
|
44
|
+
stack.apply_stack(nested_stack)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
stack
|
48
|
+
end
|
49
|
+
|
50
|
+
# Apply all stacks from an unpacked stack
|
51
|
+
#
|
52
|
+
# @param stack_name [String] name of parent stack
|
53
|
+
# @param stack [Miasma::Models::Orchestration::Stack]
|
54
|
+
# @return [Miasma::Models::Orchestration::Stack]
|
55
|
+
def apply_unpacked_stack!(stack_name, stack)
|
56
|
+
result = provider.connection.stacks.all.find_all do |remote_stack|
|
57
|
+
remote_stack.name.start_with?("#{stack_name}#{UNPACK_NAME_JOINER}")
|
58
|
+
end.sort_by(&:name).map do |remote_stack|
|
59
|
+
stack.apply_stack(remote_stack)
|
60
|
+
end
|
61
|
+
unless(result.empty?)
|
62
|
+
stack
|
63
|
+
else
|
64
|
+
ui.error "Failed to apply requested stack. Unable to locate. (#{stack_name})"
|
65
|
+
raise "Failed to locate stack: #{stack_name}"
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Unpack nested stack and run action on each stack, applying
|
70
|
+
# the previous stacks automatically
|
71
|
+
#
|
72
|
+
# @param name [String] container stack name
|
73
|
+
# @param file [Hash] stack template
|
74
|
+
# @param action [String] create or update
|
75
|
+
# @return [TrueClass]
|
76
|
+
def unpack_nesting(name, file, action)
|
77
|
+
config.apply_stacks ||= []
|
78
|
+
stack_count = 0
|
79
|
+
file['Resources'].each do |stack_resource_name, stack_resource|
|
80
|
+
|
81
|
+
nested_stack_name = "#{name}#{UNPACK_NAME_JOINER}#{Kernel.sprintf('%0.3d', stack_count)}-#{stack_resource_name}"
|
82
|
+
nested_stack_template = stack_resource['Properties']['Stack']
|
83
|
+
|
84
|
+
namespace.const_get(action.to_s.capitalize).new(
|
85
|
+
Smash.new(
|
86
|
+
:print_only => config[:print_only],
|
87
|
+
:template => nested_stack_template,
|
88
|
+
:parameters => config.fetch(:parameters, Smash.new).to_smash,
|
89
|
+
:apply_stacks => config[:apply_stacks],
|
90
|
+
:options => config[:options]
|
91
|
+
),
|
92
|
+
[nested_stack_name]
|
93
|
+
).execute!
|
94
|
+
unless(config[:print_only])
|
95
|
+
config[:apply_stacks].push(nested_stack_name).uniq!
|
96
|
+
end
|
97
|
+
config[:template] = nil
|
98
|
+
provider.connection.stacks.reload
|
99
|
+
stack_count += 1
|
100
|
+
end
|
101
|
+
|
102
|
+
true
|
103
|
+
end
|
104
|
+
|
105
|
+
# Prompt for parameter values and store result
|
106
|
+
#
|
107
|
+
# @param stack [Hash] stack template
|
108
|
+
# @return [Hash]
|
109
|
+
def populate_parameters!(stack, current_params={})
|
110
|
+
if(config[:interactive_parameters])
|
111
|
+
if(stack['Parameters'])
|
112
|
+
unless(config.get(:parameters))
|
113
|
+
config.set(:parameters, Smash.new)
|
114
|
+
end
|
115
|
+
stack.fetch('Parameters', {}).each do |k,v|
|
116
|
+
next if config[:parameters][k]
|
117
|
+
attempt = 0
|
118
|
+
valid = false
|
119
|
+
until(valid)
|
120
|
+
attempt += 1
|
121
|
+
default = config[:parameters].fetch(
|
122
|
+
k, current_params.fetch(
|
123
|
+
k, v['Default']
|
124
|
+
)
|
125
|
+
)
|
126
|
+
answer = ui.ask_question("#{k.split(/([A-Z]+[^A-Z]*)/).find_all{|s|!s.empty?}.join(' ')}", :default => default)
|
127
|
+
validation = Sfn::Utils::StackParameterValidator.validate(answer, v)
|
128
|
+
if(validation == true)
|
129
|
+
unless(answer == v['Default'])
|
130
|
+
config[:parameters][k] = answer
|
131
|
+
end
|
132
|
+
valid = true
|
133
|
+
else
|
134
|
+
validation.each do |validation_error|
|
135
|
+
ui.error validation_error.last
|
136
|
+
end
|
137
|
+
end
|
138
|
+
if(attempt > MAX_PARAMETER_ATTEMPTS)
|
139
|
+
ui.fatal 'Failed to receive allowed parameter!'
|
140
|
+
exit 1
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
stack
|
147
|
+
end
|
148
|
+
|
149
|
+
end
|
150
|
+
|
151
|
+
module ClassMethods
|
152
|
+
end
|
153
|
+
|
154
|
+
# Load methods into class and define options
|
155
|
+
#
|
156
|
+
# @param klass [Class]
|
157
|
+
def self.included(klass)
|
158
|
+
klass.class_eval do
|
159
|
+
extend Sfn::CommandModule::Stack::ClassMethods
|
160
|
+
include Sfn::CommandModule::Stack::InstanceMethods
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
require 'sfn'
|
2
|
+
require 'sparkle_formation'
|
3
|
+
|
4
|
+
module Sfn
|
5
|
+
module CommandModule
|
6
|
+
# Template handling helper methods
|
7
|
+
module Template
|
8
|
+
|
9
|
+
# cloudformation directories that should be ignored
|
10
|
+
TEMPLATE_IGNORE_DIRECTORIES = %w(components dynamics registry)
|
11
|
+
|
12
|
+
module InstanceMethods
|
13
|
+
|
14
|
+
# Load the template file
|
15
|
+
#
|
16
|
+
# @param args [Symbol] options (:allow_missing)
|
17
|
+
# @return [Hash] loaded template
|
18
|
+
def load_template_file(*args)
|
19
|
+
unless(config[:template])
|
20
|
+
set_paths_and_discover_file!
|
21
|
+
unless(File.exists?(config[:file].to_s))
|
22
|
+
unless(args.include?(:allow_missing))
|
23
|
+
ui.fatal "Invalid formation file path provided: #{config[:file]}"
|
24
|
+
raise IOError.new "Failed to locate file: #{config[:file]}"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
if(config[:template])
|
29
|
+
config[:template]
|
30
|
+
elsif(config[:file])
|
31
|
+
if(config[:processing])
|
32
|
+
sf = SparkleFormation.compile(config[:file], :sparkle)
|
33
|
+
if(sf.nested? && !sf.isolated_nests?)
|
34
|
+
raise TypeError.new('Template does not contain isolated stack nesting! Cannot process in existing state.')
|
35
|
+
end
|
36
|
+
if(sf.nested? && config[:apply_nesting])
|
37
|
+
sf.apply_nesting do |stack_name, stack_definition|
|
38
|
+
bucket = provider.connection.api_for(:storage).buckets.get(
|
39
|
+
config[:nesting_bucket]
|
40
|
+
)
|
41
|
+
if(config[:print_only])
|
42
|
+
"http://example.com/bucket/#{name_args.first}_#{stack_name}.json"
|
43
|
+
else
|
44
|
+
unless(bucket)
|
45
|
+
raise "Failed to locate configured bucket for stack template storage (#{bucket})!"
|
46
|
+
end
|
47
|
+
file = bucket.files.build
|
48
|
+
file.name = "#{name_args.first}_#{stack_name}.json"
|
49
|
+
file.content_type = 'text/json'
|
50
|
+
file.body = MultiJson.dump(Sfn::Utils::StackParameterScrubber.scrub!(stack_definition))
|
51
|
+
file.save
|
52
|
+
# TODO: what if we need extra params?
|
53
|
+
url = URI.parse(file.url)
|
54
|
+
"#{url.scheme}://#{url.host}#{url.path}"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
else
|
58
|
+
sf.dump.merge('sfn_nested_stack' => !!sf.nested?)
|
59
|
+
end
|
60
|
+
else
|
61
|
+
_from_json(File.read(config[:file]))
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Apply template translation
|
67
|
+
#
|
68
|
+
# @param template [Hash]
|
69
|
+
# @return [Hash]
|
70
|
+
def translate_template(template)
|
71
|
+
if(klass_name = config[:translate])
|
72
|
+
klass = SparkleFormation::Translation.const_get(camel(klass_name))
|
73
|
+
args = {
|
74
|
+
:parameters => config.fetch(:options, :parameters, Smash.new)
|
75
|
+
}
|
76
|
+
if(chunk_size = config[:translate_chunk_size])
|
77
|
+
args.merge!(
|
78
|
+
:options => {
|
79
|
+
:serialization_chunk_size => chunk_size
|
80
|
+
}
|
81
|
+
)
|
82
|
+
end
|
83
|
+
translator = klass.new(template, args)
|
84
|
+
translator.translate!
|
85
|
+
template = translator.translated
|
86
|
+
ui.info "#{ui.color('Translation applied:', :bold)} #{ui.color(klass_name, :yellow)}"
|
87
|
+
end
|
88
|
+
template
|
89
|
+
end
|
90
|
+
|
91
|
+
# Set SparkleFormation paths and locate tempate
|
92
|
+
#
|
93
|
+
# @return [TrueClass]
|
94
|
+
def set_paths_and_discover_file!
|
95
|
+
if(config[:base_directory])
|
96
|
+
SparkleFormation.sparkle_path = config[:base_directory]
|
97
|
+
end
|
98
|
+
if(!config[:file] && config[:file_path_prompt])
|
99
|
+
root = File.expand_path(
|
100
|
+
config.fetch(:base_directory,
|
101
|
+
File.join(Dir.pwd, 'cloudformation')
|
102
|
+
)
|
103
|
+
).split('/')
|
104
|
+
bucket = root.pop
|
105
|
+
root = root.join('/')
|
106
|
+
directory = File.join(root, bucket)
|
107
|
+
config[:file] = prompt_for_file(directory,
|
108
|
+
:directories_name => 'Collections',
|
109
|
+
:files_name => 'Templates',
|
110
|
+
:ignore_directories => TEMPLATE_IGNORE_DIRECTORIES
|
111
|
+
)
|
112
|
+
else
|
113
|
+
unless(Pathname(config[:file].to_s).absolute?)
|
114
|
+
base_dir = config[:base_directory].to_s
|
115
|
+
file = config[:file].to_s
|
116
|
+
pwd = Dir.pwd
|
117
|
+
config[:file] = [
|
118
|
+
File.join(base_dir, file),
|
119
|
+
File.join(pwd, file),
|
120
|
+
File.join(pwd, 'cloudformation', file)
|
121
|
+
].detect do |file_path|
|
122
|
+
File.file?(file_path)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
true
|
127
|
+
end
|
128
|
+
|
129
|
+
end
|
130
|
+
|
131
|
+
module ClassMethods
|
132
|
+
end
|
133
|
+
|
134
|
+
# Load methods into class and define options
|
135
|
+
#
|
136
|
+
# @param klass [Class]
|
137
|
+
def self.included(klass)
|
138
|
+
klass.class_eval do
|
139
|
+
extend Sfn::CommandModule::Template::ClassMethods
|
140
|
+
include Sfn::CommandModule::Template::InstanceMethods
|
141
|
+
include Sfn::Utils::PathSelector
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|