clc-promote 0.4.5 → 0.7.8
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +18 -8
- data/Rakefile +3 -0
- data/clc-promote.gemspec +3 -2
- data/lib/chef/knife/promote.rb +46 -17
- data/lib/kitchen/provisioner/environment.rb +1 -1
- data/lib/promote.rb +3 -2
- data/lib/promote/config.rb +11 -2
- data/lib/promote/cookbook.rb +17 -5
- data/lib/promote/git_repo.rb +22 -1
- data/lib/promote/node_finder.rb +18 -0
- data/lib/promote/promoter.rb +37 -12
- data/lib/promote/rake_tasks.rb +63 -18
- data/lib/promote/role_file.rb +26 -0
- data/lib/promote/uploader.rb +45 -13
- data/lib/promote/version.rb +2 -2
- data/lib/promote/versioner.rb +44 -9
- data/spec/unit/promote/config_spec.rb +70 -18
- data/spec/unit/promote/promoter_spec.rb +51 -8
- data/spec/unit/promote/uploader_spec.rb +97 -22
- data/spec/unit/promote/versioner_spec.rb +151 -16
- metadata +26 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 028a2d20e003fc5841ed185d5da849033b2879b2
|
4
|
+
data.tar.gz: d1fe5f381fd2ee79759d23a0809b1a7770f1698e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8f3601f98c0eab1f4191f60e9bc22221b2207787157da24665f709865b25bc147e5d4af7c0dfebed73b05f2747f4088698fd1cfc2d3a778a68a81c7172645b80
|
7
|
+
data.tar.gz: 07000fd20e24894db386e22bd178ca8268ef0efcab71d71d9d1bde7c9b1cb05844f1f7a3c987acb6281795aedfce08285cda3e10e093dc394834ce006280c61f
|
data/README.md
CHANGED
@@ -1,11 +1,11 @@
|
|
1
1
|
# CLC-Promote Gem
|
2
2
|
Drives our CI/CD pipeline and provides functionality for:
|
3
|
-
* Versioning cookbooks, data bags and environments
|
3
|
+
* Versioning cookbooks, data bags, roles, and environments
|
4
4
|
* Manages Cookbook version constraints within environments
|
5
5
|
* Manages the uploading of chef artifacts from CI to the QA chef Server and eventually to the production chef server
|
6
6
|
* Promotes one environment's cookbooks to another
|
7
7
|
|
8
|
-
This functionality is exposed via a
|
8
|
+
This functionality is exposed via a collection of rake tasks used on the CI server and a knife plugin used to perform deployments.
|
9
9
|
|
10
10
|
## Configuration
|
11
11
|
CLC-Promote uses configuration settings to determine the values of:
|
@@ -41,6 +41,7 @@ Setting |
|
|
41
41
|
:cookbook_directory | Defaults to `#{root}/cookbooks`
|
42
42
|
:environment_directory | Defaults to `#{root}/environments`
|
43
43
|
:data_bag_directory | Defaults to `#{root}/data_bags`
|
44
|
+
:role_directory | Defaults to `#{root}/roles`
|
44
45
|
:temp_directory | Defaults to `/tmp/promote`
|
45
46
|
:node_name | **mandatory**
|
46
47
|
:client_key | **mandatory**
|
@@ -48,7 +49,7 @@ Setting |
|
|
48
49
|
|
49
50
|
|
50
51
|
## CLC-Promote Rake Tasks
|
51
|
-
**Note:** See the [below section on versioning](/gems/clc-promote#How are version numbers generated) for details on how
|
52
|
+
**Note:** See the [below section on versioning](/gems/clc-promote#How are version numbers generated) for details on how version numbers are generated.
|
52
53
|
|
53
54
|
### Promote:version_cookbook
|
54
55
|
Bumps the version of an individual cookbook.
|
@@ -62,6 +63,12 @@ Bumps the version of an individual environment file.
|
|
62
63
|
### Promote:version_environments
|
63
64
|
Same as `Promote:version_environment` but iterates all environment files.
|
64
65
|
|
66
|
+
### Promote:version_role
|
67
|
+
Bumps the version of an individual role file.
|
68
|
+
|
69
|
+
### Promote:version_roles
|
70
|
+
Same as `Promote:version_role` but iterates all role files.
|
71
|
+
|
65
72
|
### Promote:version_data_bag
|
66
73
|
Bumps the version of an individual databag entry.
|
67
74
|
DEPRECATED (breaks encrypted data bags and vaults).
|
@@ -79,8 +86,11 @@ Uploads all cookbook versions of an environment to the chef server. **Note**: on
|
|
79
86
|
### Promote:upload_environment
|
80
87
|
Uploads an environment file to the chef server
|
81
88
|
|
89
|
+
### Promote:upload_roles
|
90
|
+
Uploads all role files to the chef server
|
91
|
+
|
82
92
|
### Promote:upload_data_bags
|
83
|
-
Uploads all data
|
93
|
+
Uploads all data bags to the chef server
|
84
94
|
|
85
95
|
### Promote: constrain_environment
|
86
96
|
Given an environment and its environment cookbook, this task edits the environment file and creates cookbook constraints based on the `Berksfile.lock` of the environment cookbook.
|
@@ -95,7 +105,7 @@ Version numbers are based on the last version tag which forms the major and mino
|
|
95
105
|
The knife promote command deploys an environment to production.
|
96
106
|
|
97
107
|
```
|
98
|
-
knife promote environment
|
108
|
+
knife promote environment SOURCE_ENVIRONMENT TARGET_ENVIRONMENT [ --data-bags LIST ]
|
99
109
|
```
|
100
110
|
|
101
111
|
This command performs the following:
|
@@ -103,7 +113,7 @@ This command performs the following:
|
|
103
113
|
1. Copies the cookbook version constraints from source to target
|
104
114
|
2. Commits the constraints to version control
|
105
115
|
3. Uploads the target environment to the QA chef server
|
106
|
-
4. Downloads all data bags from QA
|
116
|
+
4. Downloads all ```secrets_*``` data bags and all roles from QA. Additional data bags can be specified on the command line. Note that data bags containing chef-vault keys ( ```*_keys.json``` ) are always skipped.
|
107
117
|
5. Performs a version diff on all cookbooks between the target environment and production chef server
|
108
|
-
6. Downloads all cookbooks from
|
109
|
-
7.
|
118
|
+
6. Downloads all cookbooks from QA that are not on the production server
|
119
|
+
7. Uploads all new cookbooks, the target environment, databags and roles to the production chef server.
|
data/Rakefile
CHANGED
data/clc-promote.gemspec
CHANGED
@@ -17,8 +17,9 @@ Gem::Specification.new do |s|
|
|
17
17
|
s.test_files = s.files.grep(%r{^(test|spec|features)/})
|
18
18
|
|
19
19
|
s.add_runtime_dependency 'clc-git', '~> 1.2', '>= 1.2.8'
|
20
|
-
s.add_runtime_dependency 'berkshelf', '~> 3.2', '>= 3.2.
|
20
|
+
s.add_runtime_dependency 'berkshelf', '~> 3.2', '>= 3.2.3'
|
21
|
+
s.add_runtime_dependency 'highline', '~> 1.6', '>= 1.6.21'
|
21
22
|
|
22
23
|
s.add_development_dependency 'rspec', '~> 3.0', '>= 3.0.0'
|
23
24
|
s.add_development_dependency 'rake', '~> 10.3', '>= 10.3.2'
|
24
|
-
end
|
25
|
+
end
|
data/lib/chef/knife/promote.rb
CHANGED
@@ -6,6 +6,21 @@ module KnifePromote
|
|
6
6
|
|
7
7
|
banner "knife promote environment SOURCE_ENVIRONMENT DESTINATION_ENVIRONMENT"
|
8
8
|
|
9
|
+
option :dry,
|
10
|
+
:long => "--dry-run",
|
11
|
+
:description => "Do not perform actual promote. Just report what would be done.",
|
12
|
+
:default => false
|
13
|
+
|
14
|
+
option :bags,
|
15
|
+
:long => "--data-bags VALUE",
|
16
|
+
:description => "A comma-separated list of data bags to be promoted (in addition to all secrets_* data bags)",
|
17
|
+
:default => ['secrets_*'],
|
18
|
+
:proc => Proc.new { |b|
|
19
|
+
b = "secrets_*,#{b}"
|
20
|
+
b.split(',')
|
21
|
+
}
|
22
|
+
|
23
|
+
|
9
24
|
def run
|
10
25
|
if @name_args.length < 2
|
11
26
|
show_usage
|
@@ -16,40 +31,54 @@ module KnifePromote
|
|
16
31
|
source_env = @name_args[0]
|
17
32
|
dest_env = @name_args[1]
|
18
33
|
|
19
|
-
|
20
|
-
ui.confirm "Are you sure #{source_env} deserves this promotion"
|
21
|
-
|
22
|
-
config = Promote::Config.new({
|
34
|
+
promote_config = Promote::Config.new({
|
23
35
|
:repo_root => File.expand_path("../",Chef::Config[:cookbook_path][0]),
|
24
36
|
:node_name => Chef::Config[:node_name],
|
25
37
|
:client_key => Chef::Config[:client_key],
|
26
|
-
:chef_server_url => Chef::Config[:chef_server_url]
|
38
|
+
:chef_server_url => Chef::Config[:chef_server_url],
|
39
|
+
:temp_directory => '/tmp/promote_staging',
|
40
|
+
:bags => config[:bags]
|
27
41
|
})
|
28
42
|
destination_config = Promote::Config.new({
|
29
|
-
:repo_root =>
|
43
|
+
:repo_root => promote_config.temp_directory,
|
30
44
|
:node_name => Chef::Config[:knife][:promote_prod_user],
|
31
45
|
:client_key => Chef::Config[:knife][:promote_prod_client_key],
|
32
46
|
:chef_server_url => Chef::Config[:knife][:promote_prod_url]
|
33
47
|
})
|
34
48
|
|
35
|
-
|
36
|
-
|
37
|
-
|
49
|
+
# Confirm local git repo is in sync with origin/master
|
50
|
+
source = "environments/#{source_env}.json"
|
51
|
+
dest = "environments/#{dest_env}.json"
|
52
|
+
r = Promote::GitRepo.new(promote_config.repo_root)
|
53
|
+
r.check_sync(source, dest)
|
38
54
|
|
39
|
-
|
55
|
+
ui.info "*** #{source_env} is being promoted to #{dest_env}"
|
56
|
+
ui.confirm "Are you sure this is what you want to do"
|
40
57
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
repo.commit("promoted new constraints from #{source_env} to #{dest_env}", true)
|
58
|
+
promoter = Promote::Promoter.new(promote_config)
|
59
|
+
uploader = Promote::Uploader.new(promote_config)
|
60
|
+
Chef::Config[:verbosity] = 2
|
45
61
|
|
46
|
-
|
62
|
+
# Locally mirror the environment constraints over
|
63
|
+
promoter.promote_to(source_env, dest_env, ui)
|
64
|
+
|
65
|
+
if !config[:dry]
|
66
|
+
ui.info "Committing and pushing changes to #{dest_env} back to git"
|
67
|
+
env_file = File.join(promote_config.environment_directory, "#{dest_env}.json")
|
68
|
+
repo = Promote::GitRepo.new(env_file)
|
69
|
+
repo.commit("promoted new constraints from #{source_env} to #{dest_env}", true)
|
70
|
+
end
|
71
|
+
|
72
|
+
# Uploads the environment to QA
|
73
|
+
ui.info "uploading #{dest_env} to #{promote_config.chef_server_url}"
|
47
74
|
uploader.upload_environment(dest_env)
|
48
75
|
|
49
76
|
promoter.stage_promotion(dest_env, destination_config, ui)
|
50
|
-
promoter.upload_to(dest_env, destination_config, ui)
|
51
77
|
|
52
|
-
|
78
|
+
if !config[:dry]
|
79
|
+
promoter.upload_to(dest_env, destination_config, ui)
|
80
|
+
ui.info "promotion complete. Congratulations #{dest_env} on a well deserved promotion!!"
|
81
|
+
end
|
53
82
|
end
|
54
83
|
end
|
55
84
|
end
|
@@ -18,7 +18,7 @@ module Kitchen
|
|
18
18
|
private
|
19
19
|
|
20
20
|
def create_node
|
21
|
-
node_file = File.join(instance.
|
21
|
+
node_file = File.join(instance.verifier[:test_base_path], "nodes/#{instance.suite.name}.json")
|
22
22
|
if File.exist?(node_file)
|
23
23
|
node = JSON.parse(File.read(node_file))
|
24
24
|
node[:run_list] = config[:run_list]
|
data/lib/promote.rb
CHANGED
@@ -2,7 +2,8 @@ require 'promote/config'
|
|
2
2
|
require 'promote/cookbook'
|
3
3
|
require 'promote/environment_file'
|
4
4
|
require 'promote/git_repo'
|
5
|
-
require 'promote/
|
6
|
-
require 'promote/versioner'
|
5
|
+
require 'promote/node_finder'
|
7
6
|
require 'promote/promoter'
|
7
|
+
require 'promote/uploader'
|
8
8
|
require 'promote/utils'
|
9
|
+
require 'promote/versioner'
|
data/lib/promote/config.rb
CHANGED
@@ -2,13 +2,20 @@ module Promote
|
|
2
2
|
class Config
|
3
3
|
def initialize(options = {})
|
4
4
|
options.each do |k, v|
|
5
|
-
self.send("#{k}=", v) if self.respond_to?(k)
|
5
|
+
self.send("#{k}=", v) if self.respond_to?(k)
|
6
6
|
end
|
7
7
|
self.repo_root ||= Dir.pwd
|
8
8
|
self.cookbook_directory ||= File.join(repo_root, "cookbooks")
|
9
9
|
self.environment_directory ||= File.join(repo_root, "environments")
|
10
10
|
self.data_bag_directory ||= File.join(repo_root, "data_bags")
|
11
|
+
self.role_directory ||= File.join(repo_root, "roles")
|
11
12
|
self.temp_directory ||= "/tmp/promote"
|
13
|
+
self.bags ||= ['secrets_*']
|
14
|
+
end
|
15
|
+
|
16
|
+
def reset_temp_dir
|
17
|
+
FileUtils.rm_rf(temp_directory) if Dir.exist?(temp_directory)
|
18
|
+
Dir.mkdir(temp_directory)
|
12
19
|
end
|
13
20
|
|
14
21
|
def to_hash
|
@@ -27,9 +34,11 @@ module Promote
|
|
27
34
|
attr_accessor :cookbook_directory
|
28
35
|
attr_accessor :environment_directory
|
29
36
|
attr_accessor :data_bag_directory
|
37
|
+
attr_accessor :role_directory
|
30
38
|
attr_accessor :temp_directory
|
31
39
|
attr_accessor :node_name
|
32
40
|
attr_accessor :client_key
|
33
41
|
attr_accessor :chef_server_url
|
42
|
+
attr_accessor :bags
|
34
43
|
end
|
35
|
-
end
|
44
|
+
end
|
data/lib/promote/cookbook.rb
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
require 'berkshelf'
|
2
|
+
require 'digest/sha1'
|
2
3
|
require 'json'
|
3
4
|
|
4
5
|
module Promote
|
@@ -77,15 +78,26 @@ module Promote
|
|
77
78
|
return false
|
78
79
|
end
|
79
80
|
|
81
|
+
def dependency_hash(environment_cookbook_name)
|
82
|
+
cb_changes = sync_latest_app_cookbooks(environment_cookbook_name)
|
83
|
+
hash_src = ""
|
84
|
+
dependencies.each do | k,v |
|
85
|
+
hash_src << "#{k}::#{v}::"
|
86
|
+
end
|
87
|
+
Digest::SHA1.hexdigest(hash_src)
|
88
|
+
end
|
89
|
+
|
80
90
|
def sync_latest_app_cookbooks(environment_cookbook_name)
|
81
91
|
result = {}
|
82
92
|
latest_server_cookbooks = Utils.chef_server_cookbooks(@config, 1)
|
83
93
|
env_cookbook = Cookbook.new(environment_cookbook_name, @config)
|
84
|
-
env_cookbook.metadata_dependencies do |key|
|
85
|
-
|
86
|
-
if
|
87
|
-
|
88
|
-
|
94
|
+
env_cookbook.metadata_dependencies.each do |key|
|
95
|
+
cb_key = key[0]
|
96
|
+
next if cb_key == @name || !latest_server_cookbooks.has_key?(cb_key)
|
97
|
+
latest_version = latest_server_cookbooks[cb_key]['versions'][0]['version']
|
98
|
+
if dependencies.keys.include?(cb_key) && latest_version > dependencies[cb_key].to_s
|
99
|
+
berksfile_update(cb_key)
|
100
|
+
result[cb_key] = latest_version
|
89
101
|
end
|
90
102
|
end
|
91
103
|
result
|
data/lib/promote/git_repo.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
require 'git'
|
2
|
+
|
1
3
|
module Promote
|
2
4
|
class GitRepo
|
3
5
|
def initialize(scope_path)
|
@@ -42,6 +44,25 @@ module Promote
|
|
42
44
|
end
|
43
45
|
end
|
44
46
|
|
47
|
+
def check_sync(source, dest)
|
48
|
+
warnings = []
|
49
|
+
if git.status.changed.to_h.keys.include? source
|
50
|
+
warnings << "File #{source} has uncommitted changes."
|
51
|
+
end
|
52
|
+
git.fetch
|
53
|
+
git.diff('master','origin/master').entries.each do |f|
|
54
|
+
if f.path == source || f.path == dest
|
55
|
+
warnings << "File #{f.path} is not synced with origin/master."
|
56
|
+
end
|
57
|
+
end
|
58
|
+
if warnings.count > 0
|
59
|
+
puts '*** Git sync issues: Fix the following issues and try again:'
|
60
|
+
warnings.each { |w| puts "* #{w}" }
|
61
|
+
exit 1
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
|
45
66
|
attr_accessor :scope_path
|
46
67
|
|
47
68
|
private
|
@@ -68,4 +89,4 @@ module Promote
|
|
68
89
|
end
|
69
90
|
end
|
70
91
|
end
|
71
|
-
end
|
92
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Promote
|
2
|
+
class NodeFinder
|
3
|
+
def initialize(query, config)
|
4
|
+
@query = query
|
5
|
+
@config = config
|
6
|
+
Chef::Config.reset
|
7
|
+
Chef::Config[:client_key] = config.client_key
|
8
|
+
Chef::Config[:chef_server_url] = config.chef_server_url
|
9
|
+
Chef::Config[:node_name] = config.node_name
|
10
|
+
@searcher = Chef::Search::Query.new
|
11
|
+
end
|
12
|
+
|
13
|
+
def search
|
14
|
+
results = @searcher.search(:node, @query)
|
15
|
+
results[0]
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
data/lib/promote/promoter.rb
CHANGED
@@ -8,44 +8,68 @@ module Promote
|
|
8
8
|
@config = config
|
9
9
|
end
|
10
10
|
|
11
|
-
def promote_to(source_environment, destination_environment)
|
11
|
+
def promote_to(source_environment, destination_environment, ui = nil)
|
12
12
|
source = EnvironmentFile.new(source_environment, config)
|
13
13
|
dest = EnvironmentFile.new(destination_environment, config)
|
14
|
-
|
14
|
+
|
15
|
+
if ui
|
16
|
+
ui.info "Cookbook constraints being promoted to #{destination_environment}"
|
17
|
+
ui.info source.cookbook_versions
|
18
|
+
end
|
19
|
+
|
15
20
|
dest.write_cookbook_versions(source.cookbook_versions)
|
16
21
|
end
|
17
22
|
|
23
|
+
def monitor_promotion(source_environment, destination_environments, probe_interval, ui = nil)
|
24
|
+
destination_environments.each do |env|
|
25
|
+
promote_to(source_environment, env, ui)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
18
29
|
def stage_promotion(promote_environment, destination_config, ui)
|
19
30
|
ui.info "Staging artifacts from #{config.chef_server_url} to #{config.temp_directory}"
|
20
|
-
|
21
|
-
Dir.mkdir(config.temp_directory)
|
31
|
+
config.reset_temp_dir
|
22
32
|
|
23
33
|
deploy_file = "/environments/#{promote_environment}.json"
|
24
34
|
ui.info "Downloading #{deploy_file}..."
|
25
35
|
download_files(config.temp_directory, deploy_file)
|
36
|
+
|
26
37
|
ui.info "Downloading data bags..."
|
27
|
-
|
38
|
+
config.bags.each do |bag|
|
39
|
+
download_files(config.temp_directory, "/data_bags/#{bag}")
|
40
|
+
end
|
41
|
+
|
42
|
+
ui.info "Downloading roles..."
|
43
|
+
download_files(config.temp_directory, "/roles")
|
28
44
|
|
29
|
-
ui.info "Downloading cookbooks..."
|
45
|
+
ui.info "Downloading cookbooks missing on #{destination_config.chef_server_url}..."
|
46
|
+
ui.info "Fetching all versions of cookbooks on #{destination_config.chef_server_url}..."
|
30
47
|
server_versions = Promote::Utils.chef_server_cookbooks(destination_config)
|
48
|
+
ui.info "Fetching the environment file content of #{promote_environment}"
|
31
49
|
env_content = EnvironmentFile.new(promote_environment, Config.new({:repo_root => config.temp_directory}))
|
32
50
|
env_content.cookbook_versions.each do |k,v|
|
51
|
+
ui.info "#{k}-#{v}"
|
33
52
|
if !server_versions.has_key?(k) || (server_versions[k]["versions"].select {|version| version['version'] == v}).empty?
|
34
|
-
ui.info "
|
53
|
+
ui.info "Cookbook missing. Downloading..."
|
35
54
|
download_files(config.temp_directory, "/cookbooks/#{k}", v)
|
55
|
+
else
|
56
|
+
ui.info "Cookbook already exists."
|
36
57
|
end
|
37
|
-
end
|
58
|
+
end
|
38
59
|
end
|
39
60
|
|
40
61
|
def upload_to(promote_environment, destination_config, ui)
|
41
62
|
ui.info "Uploading staged artifacts to #{destination_config.chef_server_url}"
|
42
63
|
uploader = Uploader.new(destination_config)
|
64
|
+
Chef::Config[:verbosity] = 2
|
43
65
|
ui.info("Uploading #{promote_environment} environment file...")
|
44
66
|
uploader.upload_environment(promote_environment)
|
45
67
|
ui.info("Uploading data bags...")
|
46
68
|
uploader.upload_data_bags
|
69
|
+
ui.info("Uploading roles...")
|
70
|
+
uploader.upload_roles
|
47
71
|
ui.info("Uploading cookbooks...")
|
48
|
-
uploader.upload_cookbook_directory(destination_config.cookbook_directory)
|
72
|
+
uploader.upload_cookbook_directory(destination_config.cookbook_directory, ui)
|
49
73
|
end
|
50
74
|
|
51
75
|
private
|
@@ -55,12 +79,13 @@ module Promote
|
|
55
79
|
pattern = Chef::ChefFS::FilePattern.new(path)
|
56
80
|
local = Chef::ChefFS::FileSystem::ChefRepositoryFileSystemRootDir.new(
|
57
81
|
{
|
58
|
-
'environments' => ["#{local_root}/environments"],
|
82
|
+
'environments' => ["#{local_root}/environments"],
|
59
83
|
'data_bags' => ["#{local_root}/data_bags"],
|
84
|
+
'roles' => ["#{local_root}/roles"],
|
60
85
|
'cookbooks' => ["#{local_root}/cookbooks"]
|
61
86
|
}
|
62
87
|
)
|
63
|
-
Chef::ChefFS::FileSystem.copy_to(pattern, fs_config.chef_fs, local,
|
88
|
+
Chef::ChefFS::FileSystem.copy_to(pattern, fs_config.chef_fs, local, nil, Chef::Config)
|
64
89
|
end
|
65
90
|
end
|
66
|
-
end
|
91
|
+
end
|