bora 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/.rubocop.yml +21 -0
- data/README.md +15 -9
- data/Rakefile +5 -3
- data/bin/console +3 -3
- data/bora.gemspec +19 -18
- data/lib/bora.rb +11 -14
- data/lib/bora/cfn/change.rb +1 -1
- data/lib/bora/cfn/change_set.rb +8 -8
- data/lib/bora/cfn/change_set_action.rb +9 -10
- data/lib/bora/cfn/event.rb +12 -5
- data/lib/bora/cfn/output.rb +1 -3
- data/lib/bora/cfn/parameter.rb +0 -1
- data/lib/bora/cfn/stack.rb +28 -29
- data/lib/bora/cfn/stack_status.rb +3 -7
- data/lib/bora/cfn/status.rb +6 -9
- data/lib/bora/cli.rb +34 -32
- data/lib/bora/cli_base.rb +8 -8
- data/lib/bora/cli_change_set.rb +9 -10
- data/lib/bora/parameter_resolver.rb +17 -10
- data/lib/bora/parameter_resolver_loader.rb +1 -4
- data/lib/bora/resolver/ami.rb +0 -1
- data/lib/bora/resolver/cfn.rb +4 -4
- data/lib/bora/resolver/credstash.rb +8 -8
- data/lib/bora/resolver/hostedzone.rb +5 -7
- data/lib/bora/stack.rb +63 -70
- data/lib/bora/stack_tasks.rb +2 -7
- data/lib/bora/tasks.rb +20 -28
- data/lib/bora/template.rb +3 -2
- data/lib/bora/version.rb +1 -1
- metadata +17 -3
- data/.travis.yml +0 -4
@@ -2,15 +2,12 @@ require 'bora/cfn/status'
|
|
2
2
|
|
3
3
|
class Bora
|
4
4
|
module Cfn
|
5
|
-
|
6
5
|
class StackStatus
|
7
|
-
DOES_NOT_EXIST_MESSAGE =
|
6
|
+
DOES_NOT_EXIST_MESSAGE = 'Stack does not exist'.freeze
|
8
7
|
|
9
8
|
def initialize(underlying_stack)
|
10
9
|
@stack = underlying_stack
|
11
|
-
if @stack
|
12
|
-
@status = Status.new(@stack.stack_status)
|
13
|
-
end
|
10
|
+
@status = Status.new(@stack.stack_status) if @stack
|
14
11
|
end
|
15
12
|
|
16
13
|
def exists?
|
@@ -23,13 +20,12 @@ class Bora
|
|
23
20
|
|
24
21
|
def to_s
|
25
22
|
if @stack
|
26
|
-
status_reason = @stack.stack_status_reason ? " - #{@stack.stack_status_reason}" :
|
23
|
+
status_reason = @stack.stack_status_reason ? " - #{@stack.stack_status_reason}" : ''
|
27
24
|
"#{@stack.stack_name} - #{@status}#{status_reason}"
|
28
25
|
else
|
29
26
|
DOES_NOT_EXIST_MESSAGE
|
30
27
|
end
|
31
28
|
end
|
32
29
|
end
|
33
|
-
|
34
30
|
end
|
35
31
|
end
|
data/lib/bora/cfn/status.rb
CHANGED
@@ -8,15 +8,15 @@ class Bora
|
|
8
8
|
end
|
9
9
|
|
10
10
|
def success?
|
11
|
-
@status.end_with?(
|
11
|
+
@status.end_with?('_COMPLETE') && !@status.include?('ROLLBACK')
|
12
12
|
end
|
13
13
|
|
14
14
|
def failure?
|
15
|
-
@status.end_with?(
|
15
|
+
@status.end_with?('FAILED') || @status.include?('ROLLBACK')
|
16
16
|
end
|
17
17
|
|
18
18
|
def deleted?
|
19
|
-
@status ==
|
19
|
+
@status == 'DELETE_COMPLETE'
|
20
20
|
end
|
21
21
|
|
22
22
|
def complete?
|
@@ -27,17 +27,14 @@ class Bora
|
|
27
27
|
@status.colorize(color)
|
28
28
|
end
|
29
29
|
|
30
|
-
|
31
30
|
private
|
32
31
|
|
33
32
|
def color
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
else; :yellow;
|
33
|
+
if success? then :green
|
34
|
+
elsif failure? then :red
|
35
|
+
else; :yellow
|
38
36
|
end
|
39
37
|
end
|
40
38
|
end
|
41
|
-
|
42
39
|
end
|
43
40
|
end
|
data/lib/bora/cli.rb
CHANGED
@@ -1,101 +1,103 @@
|
|
1
|
-
require
|
2
|
-
require
|
3
|
-
require
|
4
|
-
require
|
1
|
+
require 'thor'
|
2
|
+
require 'bora'
|
3
|
+
require 'bora/cli_base'
|
4
|
+
require 'bora/cli_change_set'
|
5
5
|
|
6
6
|
class Bora
|
7
7
|
class Cli < CliBase
|
8
|
-
|
9
|
-
|
8
|
+
class_option(
|
9
|
+
:file,
|
10
10
|
type: :string,
|
11
11
|
aliases: :f,
|
12
12
|
default: Bora::DEFAULT_CONFIG_FILE,
|
13
|
-
desc:
|
14
|
-
|
15
|
-
class_option
|
13
|
+
desc: 'The Bora config file to use'
|
14
|
+
)
|
15
|
+
class_option(
|
16
|
+
:region,
|
16
17
|
type: :string,
|
17
18
|
aliases: :r,
|
18
19
|
default: nil,
|
19
|
-
desc:
|
20
|
-
|
21
|
-
class_option
|
20
|
+
desc: 'The region to use for the stack operation. Overrides any regions specified in the Bora config file.'
|
21
|
+
)
|
22
|
+
class_option(
|
23
|
+
'cfn-stack-name',
|
22
24
|
type: :string,
|
23
25
|
aliases: :n,
|
24
26
|
default: nil,
|
25
|
-
desc:
|
27
|
+
desc: 'The name to give the stack in CloudFormation. Overrides any CFN stack name setting in the Bora config file.'
|
28
|
+
)
|
26
29
|
|
27
|
-
desc
|
30
|
+
desc 'list', 'Lists the available stacks'
|
28
31
|
def list
|
29
32
|
templates = bora(options.file).templates
|
30
|
-
stacks = templates.collect
|
31
|
-
stack_names = stacks.collect
|
33
|
+
stacks = templates.collect(&:stacks).flatten
|
34
|
+
stack_names = stacks.collect(&:stack_name)
|
32
35
|
puts stack_names.join("\n")
|
33
36
|
end
|
34
37
|
|
35
|
-
desc
|
38
|
+
desc 'apply STACK_NAME', 'Creates or updates the stack'
|
36
39
|
option :params, type: :array, aliases: :p, desc: "Parameters to be passed to the template, eg: --params 'instance_type=t2.micro'"
|
37
|
-
option :pretty, type: :boolean, default: false, desc:
|
40
|
+
option :pretty, type: :boolean, default: false, desc: 'Send pretty (formatted) JSON to AWS (only works for cfndsl templates)'
|
38
41
|
def apply(stack_name)
|
39
42
|
stack(options.file, stack_name).apply(params, options.pretty)
|
40
43
|
end
|
41
44
|
|
42
|
-
desc
|
45
|
+
desc 'delete STACK_NAME', 'Deletes the stack'
|
43
46
|
def delete(stack_name)
|
44
47
|
stack(options.file, stack_name).delete
|
45
48
|
end
|
46
49
|
|
47
|
-
desc
|
50
|
+
desc 'diff STACK_NAME', "Diffs the new template with the stack's current template"
|
48
51
|
option :params, type: :array, aliases: :p, desc: "Parameters to be passed to the template, eg: --params 'instance_type=t2.micro'"
|
49
|
-
option :context, type: :numeric, aliases: :c, default: 3, desc:
|
52
|
+
option :context, type: :numeric, aliases: :c, default: 3, desc: 'Number of lines of context to show around the differences'
|
50
53
|
def diff(stack_name)
|
51
54
|
stack(options.file, stack_name).diff(params, options.context)
|
52
55
|
end
|
53
56
|
|
54
|
-
desc
|
57
|
+
desc 'events STACK_NAME', 'Outputs the latest events from the stack'
|
55
58
|
def events(stack_name)
|
56
59
|
stack(options.file, stack_name).events
|
57
60
|
end
|
58
61
|
|
59
|
-
desc
|
62
|
+
desc 'outputs STACK_NAME', 'Shows the outputs from the stack'
|
60
63
|
def outputs(stack_name)
|
61
64
|
stack(options.file, stack_name).outputs
|
62
65
|
end
|
63
66
|
|
64
|
-
desc
|
67
|
+
desc 'parameters STACK_NAME', 'Shows the parameters from the stack'
|
65
68
|
def parameters(stack_name)
|
66
69
|
stack(options.file, stack_name).parameters
|
67
70
|
end
|
68
71
|
|
69
|
-
desc
|
72
|
+
desc 'recreate STACK_NAME', 'Recreates (deletes then creates) the stack'
|
70
73
|
option :params, type: :array, aliases: :p, desc: "Parameters to be passed to the template, eg: --params 'instance_type=t2.micro'"
|
71
74
|
def recreate(stack_name)
|
72
75
|
stack(options.file, stack_name).recreate(params)
|
73
76
|
end
|
74
77
|
|
75
|
-
desc
|
78
|
+
desc 'show STACK_NAME', 'Shows the new template for stack'
|
76
79
|
option :params, type: :array, aliases: :p, desc: "Parameters to be passed to the template, eg: --params 'instance_type=t2.micro'"
|
77
80
|
def show(stack_name)
|
78
81
|
stack(options.file, stack_name).show(params)
|
79
82
|
end
|
80
83
|
|
81
|
-
desc
|
84
|
+
desc 'show_current STACK_NAME', 'Shows the current template for the stack'
|
82
85
|
def show_current(stack_name)
|
83
86
|
stack(options.file, stack_name).show_current
|
84
87
|
end
|
85
88
|
|
86
|
-
desc
|
89
|
+
desc 'status STACK_NAME', 'Displays the current status of the stack'
|
87
90
|
def status(stack_name)
|
88
91
|
stack(options.file, stack_name).status
|
89
92
|
end
|
90
93
|
|
91
|
-
desc
|
94
|
+
desc 'validate STACK_NAME', "Checks the stack's template for validity"
|
92
95
|
option :params, type: :array, aliases: :p, desc: "Parameters to be passed to the template, eg: --params 'instance_type=t2.micro'"
|
93
96
|
def validate(stack_name)
|
94
97
|
stack(options.file, stack_name).validate(params)
|
95
98
|
end
|
96
99
|
|
97
|
-
desc
|
98
|
-
subcommand
|
99
|
-
|
100
|
+
desc 'changeset SUBCOMMAND ...ARGS', 'Manage CloudFormation change sets'
|
101
|
+
subcommand 'changeset', CliChangeSet
|
100
102
|
end
|
101
103
|
end
|
data/lib/bora/cli_base.rb
CHANGED
@@ -1,11 +1,11 @@
|
|
1
|
-
require
|
1
|
+
require 'thor'
|
2
2
|
|
3
3
|
class Bora
|
4
4
|
class CliBase < Thor
|
5
5
|
# Fix for incorrect subcommand help. See https://github.com/erikhuda/thor/issues/261
|
6
|
-
def self.banner(command,
|
6
|
+
def self.banner(command, _namespace = nil, subcommand = false)
|
7
7
|
subcommand = subcommand_prefix
|
8
|
-
subcommand_str = subcommand ? " #{subcommand}" :
|
8
|
+
subcommand_str = subcommand ? " #{subcommand}" : ''
|
9
9
|
"#{basename}#{subcommand_str} #{command.usage}"
|
10
10
|
end
|
11
11
|
|
@@ -16,15 +16,15 @@ class Bora
|
|
16
16
|
no_commands do
|
17
17
|
def stack(config_file, stack_name)
|
18
18
|
region = options.region
|
19
|
-
cfn_stack_name = options[
|
19
|
+
cfn_stack_name = options['cfn-stack-name']
|
20
20
|
|
21
21
|
override_config = {}
|
22
|
-
override_config[
|
23
|
-
override_config[
|
22
|
+
override_config['default_region'] = region if region
|
23
|
+
override_config['cfn_stack_name'] = cfn_stack_name if cfn_stack_name
|
24
24
|
|
25
25
|
bora = bora(config_file, override_config)
|
26
26
|
stack = bora.stack(stack_name)
|
27
|
-
|
27
|
+
unless stack
|
28
28
|
STDERR.puts "Could not find stack #{stack_name}"
|
29
29
|
exit(1)
|
30
30
|
end
|
@@ -36,7 +36,7 @@ class Bora
|
|
36
36
|
end
|
37
37
|
|
38
38
|
def params
|
39
|
-
options.params ? Hash[options.params.map { |param| param.split(
|
39
|
+
options.params ? Hash[options.params.map { |param| param.split('=', 2) }] : {}
|
40
40
|
end
|
41
41
|
end
|
42
42
|
end
|
data/lib/bora/cli_change_set.rb
CHANGED
@@ -1,40 +1,39 @@
|
|
1
|
-
require
|
1
|
+
require 'thor'
|
2
2
|
require 'thor/group'
|
3
3
|
|
4
4
|
class Bora
|
5
5
|
class CliChangeSet < CliBase
|
6
6
|
# Fix for incorrect subcommand help. See https://github.com/erikhuda/thor/issues/261
|
7
7
|
def self.subcommand_prefix
|
8
|
-
|
8
|
+
'changeset'
|
9
9
|
end
|
10
10
|
|
11
|
-
desc
|
11
|
+
desc 'create STACK_NAME CHANGE_SET_NAME', 'Creates a change set'
|
12
12
|
option :params, type: :array, aliases: :p, desc: "Parameters to be passed to the template, eg: --params 'instance_type=t2.micro'"
|
13
|
-
option :description, type: :string, aliases: :d, desc:
|
14
|
-
option :pretty, type: :boolean, default: false, desc:
|
13
|
+
option :description, type: :string, aliases: :d, desc: 'A description for this change set'
|
14
|
+
option :pretty, type: :boolean, default: false, desc: 'Send pretty (formatted) JSON to AWS (only works for cfndsl templates)'
|
15
15
|
def create(stack_name, change_set_name)
|
16
16
|
stack(options.file, stack_name).create_change_set(change_set_name, options.description, params, options.pretty)
|
17
17
|
end
|
18
18
|
|
19
|
-
desc
|
19
|
+
desc 'list STACK_NAME', 'Lists all change sets for stack STACK_NAME'
|
20
20
|
def list(stack_name)
|
21
21
|
stack(options.file, stack_name).list_change_sets
|
22
22
|
end
|
23
23
|
|
24
|
-
desc
|
24
|
+
desc 'show STACK_NAME CHANGE_SET_NAME', 'Shows the details of the named change set'
|
25
25
|
def show(stack_name, change_set_name)
|
26
26
|
stack(options.file, stack_name).describe_change_set(change_set_name)
|
27
27
|
end
|
28
28
|
|
29
|
-
desc
|
29
|
+
desc 'delete STACK_NAME CHANGE_SET_NAME', 'Deletes the named change set'
|
30
30
|
def delete(stack_name, change_set_name)
|
31
31
|
stack(options.file, stack_name).delete_change_set(change_set_name)
|
32
32
|
end
|
33
33
|
|
34
|
-
desc
|
34
|
+
desc 'apply STACK_NAME CHANGE_SET_NAME', 'Executes the named change set'
|
35
35
|
def apply(stack_name, change_set_name)
|
36
36
|
stack(options.file, stack_name).execute_change_set(change_set_name)
|
37
37
|
end
|
38
|
-
|
39
38
|
end
|
40
39
|
end
|
@@ -5,7 +5,10 @@ class Bora
|
|
5
5
|
class ParameterResolver
|
6
6
|
UnresolvedSubstitutionError = Class.new(StandardError)
|
7
7
|
|
8
|
-
|
8
|
+
# Regular expression that can match placeholders nested to two levels.
|
9
|
+
# For example it will match: "${foo-${bar}}".
|
10
|
+
# See https://stackoverflow.com/questions/17759004/how-to-match-string-within-parentheses-nested-in-java
|
11
|
+
PLACEHOLDER_REGEX = /\${([^{}]*|{[^{}]*})*}/
|
9
12
|
|
10
13
|
def initialize(stack)
|
11
14
|
@stack = stack
|
@@ -20,7 +23,7 @@ class Bora
|
|
20
23
|
placeholders_were_substituted = false
|
21
24
|
params.each do |k, v|
|
22
25
|
resolved_value = process_param_substitutions(v, params)
|
23
|
-
unresolved_placeholders_still_remain ||=
|
26
|
+
unresolved_placeholders_still_remain ||= unresolved_placeholder?(resolved_value)
|
24
27
|
placeholders_were_substituted ||= resolved_value != v
|
25
28
|
params[k] = resolved_value
|
26
29
|
end
|
@@ -31,13 +34,18 @@ class Bora
|
|
31
34
|
params
|
32
35
|
end
|
33
36
|
|
34
|
-
|
35
37
|
private
|
36
38
|
|
37
39
|
def process_param_substitutions(val, params)
|
38
40
|
result = val
|
39
41
|
if val.is_a? String
|
40
42
|
result = val.gsub(PLACEHOLDER_REGEX) do |placeholder|
|
43
|
+
# Handle nested substitutions, like "${foo-${bar}}
|
44
|
+
if unresolved_placeholder?(placeholder)
|
45
|
+
placeholder_contents = placeholder[2..-2]
|
46
|
+
placeholder = "${#{process_param_substitutions(placeholder_contents, params)}}"
|
47
|
+
end
|
48
|
+
|
41
49
|
process_placeholder(placeholder, params)
|
42
50
|
end
|
43
51
|
elsif val.is_a? Array
|
@@ -53,7 +61,7 @@ class Bora
|
|
53
61
|
if !uri.scheme
|
54
62
|
# This token refers to another parameter, rather than a resolver
|
55
63
|
value_to_substitute = params[uri.path]
|
56
|
-
return !value_to_substitute ||
|
64
|
+
return !value_to_substitute || unresolved_placeholder?(value_to_substitute) ? placeholder : value_to_substitute
|
57
65
|
else
|
58
66
|
# This token needs to be resolved by a resolver
|
59
67
|
resolver_name = uri.scheme
|
@@ -62,14 +70,14 @@ class Bora
|
|
62
70
|
end
|
63
71
|
end
|
64
72
|
|
65
|
-
def
|
73
|
+
def unresolved_placeholder?(val)
|
66
74
|
result = false
|
67
75
|
if val.is_a? String
|
68
76
|
result = val =~ PLACEHOLDER_REGEX
|
69
77
|
elsif val.is_a? Array
|
70
|
-
result = val.find { |i|
|
78
|
+
result = val.find { |i| unresolved_placeholder?(i) }
|
71
79
|
elsif val.is_a? Hash
|
72
|
-
result = val.find { |_, v|
|
80
|
+
result = val.find { |_, v| unresolved_placeholder?(v) }
|
73
81
|
end
|
74
82
|
result
|
75
83
|
end
|
@@ -79,15 +87,14 @@ class Bora
|
|
79
87
|
|
80
88
|
# Support for legacy CFN substitutions without a scheme, eg: ${stack/outputs/foo}.
|
81
89
|
# Will be removed in next breaking version.
|
82
|
-
if !uri.scheme && uri.path && uri.path.count(
|
90
|
+
if !uri.scheme && uri.path && uri.path.count('/') == 2
|
83
91
|
uri = URI("cfn://#{s}")
|
84
92
|
end
|
85
93
|
uri
|
86
94
|
end
|
87
95
|
|
88
96
|
def unresolved_placeholders_as_string(params)
|
89
|
-
params.select { |
|
97
|
+
params.select { |_k, v| unresolved_placeholder?(v) }.to_a.map { |k, v| "#{k}: #{v}" }.join("\n")
|
90
98
|
end
|
91
|
-
|
92
99
|
end
|
93
100
|
end
|
@@ -3,7 +3,7 @@ class Bora
|
|
3
3
|
ResolverNotFound = Class.new(StandardError)
|
4
4
|
|
5
5
|
def load_resolver(name)
|
6
|
-
resolver_class = name.split(
|
6
|
+
resolver_class = name.split('_').reject(&:empty?).map(&:capitalize).join
|
7
7
|
class_name = "Bora::Resolver::#{resolver_class}"
|
8
8
|
begin
|
9
9
|
resolver_class = Kernel.const_get(class_name)
|
@@ -14,7 +14,6 @@ class Bora
|
|
14
14
|
resolver_class
|
15
15
|
end
|
16
16
|
|
17
|
-
|
18
17
|
private
|
19
18
|
|
20
19
|
def require_resolver_file(name)
|
@@ -25,7 +24,5 @@ class Bora
|
|
25
24
|
raise ResolverNotFound, "Could not find resolver for '#{name}'. Expected to find it at '#{require_path}'"
|
26
25
|
end
|
27
26
|
end
|
28
|
-
|
29
|
-
|
30
27
|
end
|
31
28
|
end
|
data/lib/bora/resolver/ami.rb
CHANGED
data/lib/bora/resolver/cfn.rb
CHANGED
@@ -14,22 +14,22 @@ class Bora
|
|
14
14
|
|
15
15
|
def resolve(uri)
|
16
16
|
stack_name = uri.host
|
17
|
-
section, name = uri.path.split(
|
17
|
+
section, name = uri.path.split('/').reject(&:empty?)
|
18
18
|
if !stack_name || !section || !name || section != 'outputs'
|
19
19
|
raise InvalidParameter, "Invalid parameter substitution: #{uri}"
|
20
20
|
end
|
21
21
|
|
22
|
-
stack_name, uri_region = stack_name.split(
|
22
|
+
stack_name, uri_region = stack_name.split('.')
|
23
23
|
region = uri_region || @stack.region
|
24
24
|
|
25
25
|
param_stack = @stack_cache[stack_name] || Bora::Cfn::Stack.new(stack_name, region)
|
26
|
-
|
26
|
+
unless param_stack.exists?
|
27
27
|
raise StackDoesNotExist, "Output #{name} not found in stack #{stack_name} as the stack does not exist"
|
28
28
|
end
|
29
29
|
|
30
30
|
outputs = param_stack.outputs || []
|
31
31
|
matching_output = outputs.find { |output| output.key == name }
|
32
|
-
|
32
|
+
unless matching_output
|
33
33
|
raise ValueNotFound, "Output #{name} not found in stack #{stack_name}"
|
34
34
|
end
|
35
35
|
|
@@ -1,5 +1,6 @@
|
|
1
1
|
require 'aws-sdk'
|
2
2
|
require 'bora/cfn/stack'
|
3
|
+
require 'English'
|
3
4
|
|
4
5
|
class Bora
|
5
6
|
module Resolver
|
@@ -11,31 +12,30 @@ class Bora
|
|
11
12
|
end
|
12
13
|
|
13
14
|
def resolve(uri)
|
14
|
-
raise InvalidParameter, "Invalid credstash parameter #{uri}: no credstash key"
|
15
|
+
raise InvalidParameter, "Invalid credstash parameter #{uri}: no credstash key" unless uri.path
|
15
16
|
key = uri.path[1..-1]
|
16
17
|
raise InvalidParameter, "Invalid credstash parameter #{uri}: no credstash key" if !key || key.empty?
|
17
18
|
region = resolve_region(uri, @stack)
|
18
19
|
context = parse_key_context(uri)
|
19
20
|
output = `credstash --region #{region} get #{key}#{context}`
|
20
|
-
exit_code = $?
|
21
|
-
raise NotFound, output
|
21
|
+
# exit_code = $?
|
22
|
+
raise NotFound, output unless $CHILD_STATUS.success?
|
22
23
|
output.rstrip
|
23
24
|
end
|
24
25
|
|
25
|
-
|
26
26
|
private
|
27
27
|
|
28
28
|
def resolve_region(uri, stack)
|
29
29
|
region = uri.host || stack.region
|
30
|
+
region
|
30
31
|
end
|
31
32
|
|
32
33
|
def parse_key_context(uri)
|
33
|
-
return
|
34
|
-
query = URI
|
35
|
-
context_params = query.map { |k,v| "#{k}=#{v}" }.join(
|
34
|
+
return '' unless uri.query
|
35
|
+
query = URI.decode_www_form(uri.query).to_h
|
36
|
+
context_params = query.map { |k, v| "#{k}=#{v}" }.join(' ')
|
36
37
|
" #{context_params}"
|
37
38
|
end
|
38
|
-
|
39
39
|
end
|
40
40
|
end
|
41
41
|
end
|