kontena-cli 0.16.3 → 0.17.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +1 -0
  3. data/.gitignore +3 -1
  4. data/VERSION +1 -1
  5. data/lib/kontena/callbacks/master/deploy/40_install_ssl_certificate_after_deploy.rb +32 -0
  6. data/lib/kontena/cli/apps/deploy_command.rb +2 -2
  7. data/lib/kontena/cli/apps/scale_command.rb +2 -2
  8. data/lib/kontena/cli/apps/show_command.rb +3 -2
  9. data/lib/kontena/cli/apps/yaml/validations.rb +10 -6
  10. data/lib/kontena/cli/apps/yaml/validator.rb +1 -0
  11. data/lib/kontena/cli/apps/yaml/validator_v2.rb +1 -0
  12. data/lib/kontena/cli/cloud/login_command.rb +66 -64
  13. data/lib/kontena/cli/common.rb +0 -10
  14. data/lib/kontena/cli/grids/logs_command.rb +0 -1
  15. data/lib/kontena/cli/localhost_web_server.rb +11 -3
  16. data/lib/kontena/cli/master/login_command.rb +213 -163
  17. data/lib/kontena/cli/nodes/label_command.rb +2 -0
  18. data/lib/kontena/cli/nodes/labels/add_command.rb +7 -8
  19. data/lib/kontena/cli/nodes/labels/list_command.rb +17 -0
  20. data/lib/kontena/cli/nodes/labels/remove_command.rb +7 -12
  21. data/lib/kontena/cli/nodes/show_command.rb +1 -0
  22. data/lib/kontena/cli/plugins/common.rb +8 -0
  23. data/lib/kontena/cli/plugins/install_command.rb +21 -2
  24. data/lib/kontena/cli/plugins/list_command.rb +4 -2
  25. data/lib/kontena/cli/plugins/search_command.rb +4 -2
  26. data/lib/kontena/cli/registry/create_command.rb +19 -12
  27. data/lib/kontena/cli/registry/remove_command.rb +4 -4
  28. data/lib/kontena/cli/registry_command.rb +0 -1
  29. data/lib/kontena/cli/services/create_command.rb +6 -6
  30. data/lib/kontena/cli/services/deploy_command.rb +8 -4
  31. data/lib/kontena/cli/services/list_command.rb +34 -21
  32. data/lib/kontena/cli/services/logs_command.rb +1 -1
  33. data/lib/kontena/cli/services/scale_command.rb +3 -3
  34. data/lib/kontena/cli/services/services_helper.rb +18 -14
  35. data/lib/kontena/cli/services/show_command.rb +1 -0
  36. data/lib/kontena/cli/services/update_command.rb +6 -6
  37. data/lib/kontena/cli/stack_command.rb +12 -6
  38. data/lib/kontena/cli/stacks/build_command.rb +110 -0
  39. data/lib/kontena/cli/stacks/common.rb +85 -20
  40. data/lib/kontena/cli/stacks/deploy_command.rb +30 -7
  41. data/lib/kontena/cli/stacks/install_command.rb +30 -0
  42. data/lib/kontena/cli/stacks/list_command.rb +74 -14
  43. data/lib/kontena/cli/stacks/logs_command.rb +31 -0
  44. data/lib/kontena/cli/stacks/monitor_command.rb +91 -0
  45. data/lib/kontena/cli/stacks/remove_command.rb +24 -7
  46. data/lib/kontena/cli/stacks/service_generator.rb +115 -0
  47. data/lib/kontena/cli/stacks/service_generator_v2.rb +27 -0
  48. data/lib/kontena/cli/stacks/show_command.rb +65 -13
  49. data/lib/kontena/cli/stacks/upgrade_command.rb +28 -0
  50. data/lib/kontena/cli/stacks/yaml/custom_validators/affinities_validator.rb +19 -0
  51. data/lib/kontena/cli/stacks/yaml/custom_validators/build_validator.rb +22 -0
  52. data/lib/kontena/cli/stacks/yaml/custom_validators/extends_validator.rb +21 -0
  53. data/lib/kontena/cli/stacks/yaml/custom_validators/hooks_validator.rb +54 -0
  54. data/lib/kontena/cli/stacks/yaml/custom_validators/secrets_validator.rb +22 -0
  55. data/lib/kontena/cli/stacks/yaml/reader.rb +219 -0
  56. data/lib/kontena/cli/stacks/yaml/service_extender.rb +78 -0
  57. data/lib/kontena/cli/stacks/yaml/validations.rb +71 -0
  58. data/lib/kontena/cli/stacks/yaml/validator_v3.rb +52 -0
  59. data/lib/kontena/cli/version_command.rb +5 -1
  60. data/lib/kontena/cli/vpn/create_command.rb +20 -17
  61. data/lib/kontena/cli/vpn/remove_command.rb +4 -3
  62. data/lib/kontena/client.rb +21 -20
  63. data/lib/kontena/machine/cert_helper.rb +4 -0
  64. data/lib/kontena/machine/cloud_config/cloudinit.yml +1 -1
  65. data/lib/kontena/main_command.rb +1 -1
  66. data/spec/fixtures/kontena-build.yml +2 -2
  67. data/spec/fixtures/kontena-invalid.yml +1 -1
  68. data/spec/fixtures/kontena-not-hash-service-config.yml +1 -1
  69. data/spec/fixtures/kontena-with-env-file.yml +2 -2
  70. data/spec/fixtures/kontena_build_v3.yml +23 -0
  71. data/spec/fixtures/kontena_v3.yml +20 -0
  72. data/spec/fixtures/stack-internal-extend.yml +11 -0
  73. data/spec/fixtures/stack-with-env-file.yml +21 -0
  74. data/spec/fixtures/stack-with-variables.yml +22 -0
  75. data/spec/kontena/cli/app/scale_spec.rb +3 -1
  76. data/spec/kontena/cli/cloud/login_command_spec.rb +283 -0
  77. data/spec/kontena/cli/master/login_command_spec.rb +324 -145
  78. data/spec/kontena/cli/services/link_command_spec.rb +1 -1
  79. data/spec/kontena/cli/services/secrets/link_command_spec.rb +4 -4
  80. data/spec/kontena/cli/services/secrets/unlink_command_spec.rb +2 -2
  81. data/spec/kontena/cli/services/services_helper_spec.rb +15 -11
  82. data/spec/kontena/cli/services/unlink_command_spec.rb +1 -1
  83. data/spec/kontena/cli/stacks/deploy_command_spec.rb +26 -0
  84. data/spec/kontena/cli/stacks/install_command_spec.rb +54 -0
  85. data/spec/kontena/cli/stacks/list_command_spec.rb +27 -0
  86. data/spec/kontena/cli/stacks/remove_command_spec.rb +45 -0
  87. data/spec/kontena/cli/stacks/service_generator_spec.rb +385 -0
  88. data/spec/kontena/cli/stacks/service_generator_v2_spec.rb +74 -0
  89. data/spec/kontena/cli/stacks/show_command_spec.rb +26 -0
  90. data/spec/kontena/cli/stacks/upgrade_command_spec.rb +50 -0
  91. data/spec/kontena/cli/stacks/yaml/reader_spec.rb +370 -0
  92. data/spec/kontena/cli/stacks/yaml/service_extender_spec.rb +128 -0
  93. data/spec/kontena/cli/stacks/yaml/validator_v3_spec.rb +302 -0
  94. data/spec/spec_helper.rb +6 -4
  95. data/spec/support/client_helpers.rb +1 -0
  96. metadata +57 -7
  97. data/lib/kontena/cli/registry/delete_command.rb +0 -18
  98. data/lib/kontena/cli/stacks/create_command.rb +0 -27
  99. data/lib/kontena/cli/stacks/update_command.rb +0 -27
@@ -0,0 +1,22 @@
1
+ module Kontena::Cli::Stacks::YAML::Validations::CustomValidators
2
+ class BuildValidator < HashValidator::Validator::Base
3
+ def initialize
4
+ super('stacks_valid_build')
5
+ end
6
+
7
+ def validate(key, value, validations, errors)
8
+ unless value.is_a?(String) || value.is_a?(Hash)
9
+ errors[key] = 'build must be string or hash'
10
+ return
11
+ end
12
+ if value.is_a?(Hash)
13
+ build_validation = {
14
+ 'context' => 'string',
15
+ 'dockerfile' => HashValidator.optional('string'),
16
+ 'args' => HashValidator.optional(-> (value) { value.is_a?(Array) || value.is_a?(Hash) })
17
+ }
18
+ HashValidator.validator_for(build_validation).validate(key, value, build_validation, errors)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ module Kontena::Cli::Stacks::YAML::Validations::CustomValidators
2
+ class ExtendsValidator < HashValidator::Validator::Base
3
+ def initialize
4
+ super('stacks_valid_extends')
5
+ end
6
+
7
+ def validate(key, value, validations, errors)
8
+ unless value.is_a?(String) || value.is_a?(Hash)
9
+ errors[key] = 'extends must be string or hash'
10
+ return
11
+ end
12
+ if value.is_a?(Hash)
13
+ extends_validation = {
14
+ 'service' => 'string',
15
+ 'file' => HashValidator.optional('string')
16
+ }
17
+ HashValidator.validator_for(extends_validation).validate(key, value, extends_validation, errors)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,54 @@
1
+ module Kontena::Cli::Stacks::YAML::Validations::CustomValidators
2
+ class HooksValidator < HashValidator::Validator::Base
3
+ def initialize
4
+ super('stacks_valid_hooks')
5
+ end
6
+
7
+ def validate(key, value, validations, errors)
8
+ unless value.is_a?(Hash)
9
+ errors[key] = 'hooks must be array'
10
+ return
11
+ end
12
+
13
+ if value['pre_build']
14
+ validate_pre_build_hooks(key, value['pre_build'], errors)
15
+ end
16
+
17
+ if value['post_start']
18
+ validate_post_start_hooks(key, value['post_start'], errors)
19
+ end
20
+ end
21
+
22
+ def validate_pre_build_hooks(key, pre_build_hooks, errors)
23
+ unless pre_build_hooks.is_a?(Array)
24
+ errors[key] = 'pre_build must be array'
25
+ return
26
+ end
27
+ pre_build_validation = {
28
+ 'name' => 'string',
29
+ 'cmd' => 'string'
30
+ }
31
+ validator = HashValidator.validator_for(pre_build_validation)
32
+ pre_build_hooks.each do |pre_build|
33
+ validator.validate('hooks.pre_build', pre_build, pre_build_validation, errors)
34
+ end
35
+ end
36
+
37
+ def validate_post_start_hooks(key, post_start_hooks, errors)
38
+ unless post_start_hooks.is_a?(Array)
39
+ errors[key] = 'post_start must be array'
40
+ return
41
+ end
42
+ post_start_validation = {
43
+ 'name' => 'string',
44
+ 'instances' => (-> (value) { value.is_a?(Integer) || value == '*' }),
45
+ 'cmd' => 'string',
46
+ 'oneshot' => HashValidator.optional('boolean')
47
+ }
48
+ validator = HashValidator.validator_for(post_start_validation)
49
+ post_start_hooks.each do |post_start|
50
+ validator.validate('hooks.post_start', post_start, post_start_validation, errors)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,22 @@
1
+ module Kontena::Cli::Stacks::YAML::Validations::CustomValidators
2
+ class SecretsValidator < HashValidator::Validator::Base
3
+ def initialize
4
+ super('stacks_valid_secrets')
5
+ end
6
+
7
+ def validate(key, value, validations, errors)
8
+ unless value.is_a?(Array)
9
+ errors[key] = 'secrets must be array'
10
+ return
11
+ end
12
+ secret_item_validation = {
13
+ 'secret' => 'string',
14
+ 'name' => 'string',
15
+ 'type' => 'string'
16
+ }
17
+ value.each do |secret|
18
+ HashValidator.validator_for(secret_item_validation).validate(key, secret, secret_item_validation, errors)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,219 @@
1
+ require 'yaml'
2
+ require_relative 'service_extender'
3
+ require_relative 'validator_v3'
4
+ require_relative '../../../util'
5
+
6
+ module Kontena::Cli::Stacks
7
+ module YAML
8
+ class Reader
9
+ include Kontena::Util
10
+ attr_reader :yaml, :file, :errors, :notifications
11
+
12
+ def initialize(file, skip_validation = false)
13
+ @file = file
14
+ @errors = []
15
+ @notifications = []
16
+ @skip_validation = skip_validation
17
+ parse_yaml
18
+ end
19
+
20
+ ##
21
+ # @param [String] service_name
22
+ # @return [Hash]
23
+ def execute(service_name = nil)
24
+ result = {}
25
+ Dir.chdir(File.dirname(File.expand_path(file))) do
26
+ result[:version] = yaml['version'] || '1'
27
+ result[:stack] = yaml['stack']
28
+ result[:name] = self.stack_name
29
+ result[:expose] = yaml['expose']
30
+ result[:errors] = errors
31
+ result[:notifications] = notifications
32
+ result[:services] = parse_services(service_name) unless errors.count > 0
33
+ end
34
+ result
35
+ end
36
+
37
+ def reload
38
+ @errors = []
39
+ @notifications = []
40
+ parse_yaml
41
+ end
42
+
43
+ def stack_name
44
+ yaml['stack'].split('/').last if yaml['stack']
45
+ end
46
+
47
+ # @return [String]
48
+ def raw
49
+ read_content
50
+ end
51
+
52
+ private
53
+
54
+ def parse_yaml
55
+ load_yaml
56
+ validate unless skip_validation?
57
+ end
58
+
59
+ def read_content
60
+ @content ||= File.read(File.expand_path(file))
61
+ end
62
+
63
+ def load_yaml
64
+ content = read_content.dup
65
+ interpolate(content)
66
+ replace_dollar_dollars(content)
67
+ begin
68
+ @yaml = ::YAML.load(content)
69
+ rescue Psych::SyntaxError => e
70
+ raise "Error while parsing #{file}".colorize(:red)+ " "+e.message
71
+ end
72
+ end
73
+
74
+ # @return [Array] array of validation errors
75
+ def validate
76
+ result = validator.validate(yaml)
77
+ store_failures(result)
78
+ result
79
+ end
80
+
81
+ def skip_validation?
82
+ @skip_validation == true
83
+ end
84
+
85
+ def store_failures(data)
86
+ errors << { file => data[:errors] } unless data[:errors].empty?
87
+ notifications << { file => data[:notifications] } unless data[:notifications].empty?
88
+ end
89
+
90
+ # @return [Kontena::Cli::Stacks::YAML::ValidatorV3]
91
+ def validator
92
+ @validator ||= YAML::ValidatorV3.new
93
+ end
94
+
95
+ ##
96
+ # @param [String] service_name - optional service to parse
97
+ # @return [Hash]
98
+ def parse_services(service_name = nil)
99
+ if service_name.nil?
100
+ services.each do |name, config|
101
+ services[name] = process_config(config)
102
+ end
103
+ services
104
+ else
105
+ raise ("Service '#{service_name}' not found in #{file}") unless services.key?(service_name)
106
+ process_config(services[service_name])
107
+ end
108
+ end
109
+
110
+ # @param [Hash] service_config
111
+ def process_config(service_config)
112
+ normalize_env_vars(service_config)
113
+ merge_env_vars(service_config)
114
+ expand_build_context(service_config)
115
+ normalize_build_args(service_config)
116
+ service_config = extend_config(service_config) if service_config.key?('extends')
117
+ service_config
118
+ end
119
+
120
+ # @return [Hash] - services from YAML file
121
+ def services
122
+ yaml['services']
123
+ end
124
+
125
+ ##
126
+ # @param [String] text - content of YAML file
127
+ def interpolate(text)
128
+ text.gsub!(/(?<!\$)\$(?!\$)\{?\w+\}?/) do |v| # searches $VAR and ${VAR} and not $$VAR
129
+ var = v.tr('${}', '')
130
+ puts "The #{var} is not set. Substituting an empty string." if !ENV.key?(var) && !skip_validation?
131
+ ENV[var] # replace with equivalent ENV variables
132
+ end
133
+ end
134
+
135
+ ##
136
+ # @param [String] text - content of yaml file
137
+ def replace_dollar_dollars(text)
138
+ text.gsub!('$$', '$')
139
+ end
140
+
141
+ # @param [Hash] service_config
142
+ # @return [Hash] updated service config
143
+ def extend_config(service_config)
144
+ extended_service = extended_service(service_config['extends'])
145
+ return unless extended_service
146
+ filename = service_config['extends']['file']
147
+ if filename
148
+ parent_config = from_external_file(filename, extended_service)
149
+ else
150
+ raise ("Service '#{extended_service}' not found in #{file}") unless services.key?(extended_service)
151
+ parent_config = process_config(services[extended_service])
152
+ end
153
+ ServiceExtender.new(service_config).extend_from(parent_config)
154
+ end
155
+
156
+ def extended_service(extend_config)
157
+ if extend_config.is_a?(Hash)
158
+ extend_config['service']
159
+ elsif extend_config.is_a?(String)
160
+ extend_config
161
+ else
162
+ nil
163
+ end
164
+ end
165
+
166
+ def from_external_file(filename, service_name)
167
+ outcome = Reader.new(filename, @skip_validation).execute(service_name)
168
+ errors.concat outcome[:errors] unless errors.any? { |item| item.key?(filename) }
169
+ notifications.concat outcome[:notifications] unless notifications.any? { |item| item.key?(filename) }
170
+ outcome[:services]
171
+ end
172
+
173
+ # @param [Hash] options - service config
174
+ def normalize_env_vars(options)
175
+ if options['environment'].is_a?(Hash)
176
+ options['environment'] = options['environment'].map { |k, v| "#{k}=#{v}" }
177
+ end
178
+ end
179
+
180
+ # @param [Hash] options
181
+ def merge_env_vars(options)
182
+ return options['environment'] unless options['env_file']
183
+
184
+ options['env_file'] = [options['env_file']] if options['env_file'].is_a?(String)
185
+ options['environment'] = [] unless options['environment']
186
+ options['env_file'].each do |env_file|
187
+ options['environment'].concat(read_env_file(env_file))
188
+ end
189
+ options.delete('env_file')
190
+ options['environment'].uniq! { |s| s.split('=').first }
191
+ end
192
+
193
+ # @param [String] path
194
+ def read_env_file(path)
195
+ File.readlines(path).map { |line| line.strip }.delete_if { |line| line.start_with?('#') || line.empty? }
196
+ end
197
+
198
+ def expand_build_context(options)
199
+ if options['build'].is_a?(String)
200
+ options['build'] = File.expand_path(options['build'])
201
+ elsif context = options.dig('build', 'context')
202
+ options['build']['context'] = File.expand_path(context)
203
+ end
204
+ end
205
+
206
+ # @param [Hash] options - service config
207
+ def normalize_build_args(options)
208
+ if safe_dig(options, 'build', 'args').is_a?(Array)
209
+ args = options['build']['args'].dup
210
+ options['build']['args'] = {}
211
+ args.each do |arg|
212
+ k,v = arg.split('=')
213
+ options['build']['args'][k] = v
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,78 @@
1
+ require 'yaml'
2
+ require_relative '../../../util'
3
+
4
+ module Kontena::Cli::Stacks
5
+ module YAML
6
+ class ServiceExtender
7
+ include Kontena::Util
8
+ attr_reader :service_config
9
+
10
+ # @param [Hash] service_config
11
+ def initialize(service_config)
12
+ @service_config = service_config
13
+ end
14
+
15
+ # @param [Hash] from
16
+ # @return [Hash]
17
+ def extend_from(from)
18
+ service_config['environment'] = extend_env_vars(
19
+ from['environment'],
20
+ service_config['environment']
21
+ )
22
+ service_config['secrets'] = extend_secrets(
23
+ from['secrets'],
24
+ service_config['secrets']
25
+ )
26
+ build_args = extend_build_args(safe_dig(from, 'build', 'args'), safe_dig(service_config, 'build', 'args'))
27
+ unless build_args.empty?
28
+ service_config['build'] = {} unless service_config['build']
29
+ service_config['build']['args'] = build_args
30
+ end
31
+
32
+ from.merge(service_config)
33
+ end
34
+
35
+ private
36
+
37
+ # @param [Array] from
38
+ # @param [Array] to
39
+ # @return [Array]
40
+ def extend_env_vars(from, to)
41
+ env_vars = to || []
42
+ if from
43
+ from.each do |env|
44
+ env_vars << env unless to && to.find do |key|
45
+ key.split('=').first == env.split('=').first
46
+ end
47
+ end
48
+ end
49
+ env_vars
50
+ end
51
+
52
+ # @param [Array] from
53
+ # @param [Array] to
54
+ # @return [Array]
55
+ def extend_secrets(from, to)
56
+ secrets = to || []
57
+ if from
58
+ from.each do |from_secret|
59
+ secrets << from_secret unless to && to.any? do |to_secret|
60
+ to_secret['secret'] == from_secret['secret']
61
+ end
62
+ end
63
+ end
64
+ secrets
65
+ end
66
+
67
+ def extend_build_args(from, to)
68
+ args = to || {}
69
+ if from
70
+ from.each do |k,v|
71
+ args[k] = v unless args.has_key?(k)
72
+ end
73
+ end
74
+ args
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,71 @@
1
+ module Kontena::Cli::Stacks::YAML
2
+ module Validations
3
+ module CustomValidators
4
+ require_relative 'custom_validators/affinities_validator'
5
+ require_relative 'custom_validators/build_validator'
6
+ require_relative 'custom_validators/extends_validator'
7
+ require_relative 'custom_validators/hooks_validator'
8
+ require_relative 'custom_validators/secrets_validator'
9
+
10
+ def self.load
11
+ return if @loaded
12
+ HashValidator.append_validator(AffinitiesValidator.new)
13
+ HashValidator.append_validator(BuildValidator.new)
14
+ HashValidator.append_validator(ExtendsValidator.new)
15
+ HashValidator.append_validator(SecretsValidator.new)
16
+ HashValidator.append_validator(HooksValidator.new)
17
+ @loaded = true
18
+ end
19
+ end
20
+
21
+ def common_validations
22
+ {
23
+ 'image' => optional('string'), # it's optional because some base yml file might contain image option
24
+ 'extends' => optional('stacks_valid_extends'),
25
+ 'stateful' => optional('boolean'),
26
+ 'affinity' => optional('stacks_valid_affinities'),
27
+ 'cap_add' => optional('array'),
28
+ 'cap_drop' => optional('array'),
29
+ 'command' => optional('string'),
30
+ 'cpu_shares' => optional('integer'),
31
+ 'external_links' => optional('array'),
32
+ 'mem_limit' => optional('string'),
33
+ 'mem_swaplimit' => optional('string'),
34
+ 'environment' => optional(-> (value) { value.is_a?(Array) || value.is_a?(Hash) }),
35
+ 'env_file' => optional(-> (value) { value.is_a?(String) || value.is_a?(Array) }),
36
+ 'instances' => optional('integer'),
37
+ 'links' => optional(-> (value) { value.is_a?(Array) || value.nil? }),
38
+ 'ports' => optional('array'),
39
+ 'pid' => optional('string'),
40
+ 'privileged' => optional('boolean'),
41
+ 'user' => optional('string'),
42
+ 'volumes' => optional('array'),
43
+ 'volumes_from' => optional('array'),
44
+ 'secrets' => optional('stacks_valid_secrets'),
45
+ 'hooks' => optional('stacks_valid_hooks'),
46
+ 'deploy' => optional({
47
+ 'strategy' => optional(%w(ha daemon random)),
48
+ 'wait_for_port' => optional('integer'),
49
+ 'min_health' => optional('float'),
50
+ 'interval' => optional(/^\d+(min|h|d|)$/)
51
+ }),
52
+ 'health_check' => optional({
53
+ 'protocol' => /^(http|tcp)$/,
54
+ 'port' => 'integer',
55
+ 'uri' => optional(/\/[\S]*/),
56
+ 'timeout' => optional('integer'),
57
+ 'interval' => optional('integer'),
58
+ 'initial_delay' => optional('integer')
59
+ })
60
+ }
61
+ end
62
+
63
+ def optional(type)
64
+ HashValidator.optional(type)
65
+ end
66
+
67
+ def validate_options(service_config)
68
+ HashValidator.validate(service_config, @schema, true)
69
+ end
70
+ end
71
+ end