hyrb 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +61 -0
  6. data/Rakefile +8 -0
  7. data/bin/hyrb +9 -0
  8. data/hyrb.gemspec +35 -0
  9. data/lib/hyrb/cli.rb +27 -0
  10. data/lib/hyrb/command.rb +74 -0
  11. data/lib/hyrb/commands/ansible.rb +13 -0
  12. data/lib/hyrb/commands/creds.rb +20 -0
  13. data/lib/hyrb/commands/defaults.rb +20 -0
  14. data/lib/hyrb/commands/developers.rb +15 -0
  15. data/lib/hyrb/commands/digital_ocean.rb +15 -0
  16. data/lib/hyrb/commands/environment.rb +11 -0
  17. data/lib/hyrb/commands/github.rb +15 -0
  18. data/lib/hyrb/commands/hipchat.rb +20 -0
  19. data/lib/hyrb/commands/project.rb +36 -0
  20. data/lib/hyrb/commands/provision.rb +17 -0
  21. data/lib/hyrb/commands/rackspace.rb +11 -0
  22. data/lib/hyrb/model.rb +96 -0
  23. data/lib/hyrb/models/ansible_host.rb +28 -0
  24. data/lib/hyrb/models/ansible_site.rb +46 -0
  25. data/lib/hyrb/models/cache.rb +35 -0
  26. data/lib/hyrb/models/creds.rb +24 -0
  27. data/lib/hyrb/models/defaults.rb +11 -0
  28. data/lib/hyrb/models/developer.rb +28 -0
  29. data/lib/hyrb/models/environment.rb +41 -0
  30. data/lib/hyrb/models/project.rb +35 -0
  31. data/lib/hyrb/pipeline.rb +63 -0
  32. data/lib/hyrb/task.rb +46 -0
  33. data/lib/hyrb/tasks/ansible.rb +96 -0
  34. data/lib/hyrb/tasks/creds.rb +39 -0
  35. data/lib/hyrb/tasks/defaults.rb +43 -0
  36. data/lib/hyrb/tasks/developers.rb +120 -0
  37. data/lib/hyrb/tasks/digital_ocean.rb +84 -0
  38. data/lib/hyrb/tasks/environment.rb +56 -0
  39. data/lib/hyrb/tasks/github.rb +120 -0
  40. data/lib/hyrb/tasks/google.rb +15 -0
  41. data/lib/hyrb/tasks/hipchat.rb +76 -0
  42. data/lib/hyrb/tasks/project/bootstrap.rb +88 -0
  43. data/lib/hyrb/tasks/project.rb +48 -0
  44. data/lib/hyrb/tasks/provision.rb +91 -0
  45. data/lib/hyrb/tasks/rackspace.rb +95 -0
  46. data/lib/hyrb/version.rb +3 -0
  47. data/lib/hyrb.rb +18 -0
  48. data/lib/templates/ansible/Vagrantfile.erb +21 -0
  49. data/lib/templates/ansible/roles/db/handlers/main.yml +2 -0
  50. data/lib/templates/ansible/roles/db/tasks/main.yml +24 -0
  51. data/lib/templates/ansible/roles/lamp/tasks/main.yml +53 -0
  52. data/lib/templates/ansible/roles/lamp.yml +6 -0
  53. data/lib/templates/ansible/roles/site/handlers/main.yml +3 -0
  54. data/lib/templates/ansible/roles/site/tasks/db.yml +23 -0
  55. data/lib/templates/ansible/roles/site/tasks/main.yml +16 -0
  56. data/lib/templates/ansible/roles/site/templates/vhost.conf.j2 +24 -0
  57. data/lib/templates/ansible/site.yml +10 -0
  58. data/lib/templates/roles/db/handlers/main.yml +2 -0
  59. data/lib/templates/roles/db/tasks/main.yml +24 -0
  60. data/lib/templates/roles/lamp/tasks/main.yml +36 -0
  61. data/lib/templates/roles/lamp.yml +6 -0
  62. data/lib/templates/roles/site/handlers/main.yml +3 -0
  63. data/lib/templates/roles/site/tasks/db.yml +23 -0
  64. data/lib/templates/roles/site/tasks/main.yml +16 -0
  65. data/lib/templates/roles/site/templates/vhost.conf.j2 +24 -0
  66. data/spec/hyrb/pipeline_spec.rb +91 -0
  67. data/spec/spec_helper.rb +5 -0
  68. metadata +295 -0
@@ -0,0 +1,35 @@
1
+ module Hyrb
2
+ module Models
3
+ module Cache
4
+ class Developers < Model
5
+ def initialize(data = nil)
6
+ super(File.join("cache", "developers"), data)
7
+ end
8
+
9
+ def serialize(data)
10
+ data.map(&:to_hash)
11
+ end
12
+
13
+ def deserialize(data)
14
+ data && data.map { |d| Developer.new(d) }
15
+ end
16
+ end
17
+
18
+ class DigitalOcean < Struct
19
+ define_keys %w( flavors images regions )
20
+
21
+ def initialize
22
+ super(File.join("cache", "digital_ocean"))
23
+ end
24
+ end
25
+
26
+ class Rackspace < Struct
27
+ define_keys %w( flavors images )
28
+
29
+ def initialize
30
+ super(File.join("cache", "rackspace"))
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,24 @@
1
+ module Hyrb
2
+ module Models
3
+ class Creds < Struct
4
+ define_keys %w( rackspace_api_key
5
+ rackspace_username
6
+ digital_ocean_api_key
7
+ digital_ocean_client_id
8
+ aws_access_key_id
9
+ aws_secret_access_key
10
+ github_org
11
+ github_api_key
12
+ hipchat_api_key
13
+ hipchat_user_id
14
+ google_client_id
15
+ google_client_secret
16
+ google_refresh_token
17
+ google_spreadsheet_key )
18
+
19
+ def initialize
20
+ super("creds")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,11 @@
1
+ module Hyrb
2
+ module Models
3
+ class Defaults < Struct
4
+ define_keys %w( project_path domain admin_email rackspace_region )
5
+
6
+ def initialize
7
+ super("defaults")
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,28 @@
1
+ module Hyrb
2
+ module Models
3
+ class Developer
4
+ ROLE_MAP = {
5
+ 1 => :admin,
6
+ 2 => :developer,
7
+ 3 => :contractor,
8
+ 4 => :banned,
9
+ }
10
+
11
+ attr_accessor :name, :email, :role, :github_username, :keys, :digital_ocean_id
12
+
13
+ def initialize(hash = {})
14
+ hash.each do |k,v|
15
+ send("#{k}=", v)
16
+ end
17
+ end
18
+
19
+ def employee?
20
+ [:admin, :developer].include?(role)
21
+ end
22
+
23
+ def to_hash
24
+ Hash[%w(name email role github_username keys).map { |a| [a, send(a)] }]
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,41 @@
1
+ module Hyrb
2
+ module Models
3
+ class Environment < Struct
4
+ PROVIDERS = %w( digital_ocean rackspace )
5
+
6
+ attr_accessor :project
7
+
8
+ define_keys %w( name
9
+ host
10
+ domain
11
+ label
12
+ server_name
13
+ provider
14
+ server_id
15
+ deploy_user
16
+ base_path
17
+ relative_web_root
18
+ database_host
19
+ database_name
20
+ database_user
21
+ database_password
22
+ has_dns_record )
23
+
24
+ def initialize(project, name)
25
+ @name = name
26
+ super(File.join("projects", project.name, "project"))
27
+ self.project = project
28
+ self.name = name
29
+ end
30
+
31
+ def serialize(data)
32
+ project.environments[@name] = data
33
+ project.serialize(project.data)
34
+ end
35
+
36
+ def deserialize(data)
37
+ super(data["environments"][@name])
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,35 @@
1
+ module Hyrb
2
+ module Models
3
+ class Project < Struct
4
+ define_keys %w( name
5
+ title
6
+ description
7
+ github_org
8
+ repo_name
9
+ github_team
10
+ room_name
11
+ users
12
+ environments
13
+ jira_project
14
+ has_hipchat_hook )
15
+
16
+ def initialize(name)
17
+ super(File.join("projects", name, "project"))
18
+ self.name = name
19
+ data.environments ||= { }
20
+ end
21
+
22
+ def developers(all_developers)
23
+ users ? all_developers.select {|dev| users.include? dev.email } : []
24
+ end
25
+
26
+ def repo_path
27
+ "#{github_org}/#{repo_name}"
28
+ end
29
+
30
+ def ansible_path
31
+ File.join(File.dirname(path), "ansible")
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,63 @@
1
+ require 'tsort'
2
+
3
+ module Hyrb
4
+ class Pipeline
5
+ include TSort
6
+
7
+ attr_accessor :prev_tasks, :next_tasks, :current_task, :env
8
+
9
+ def self.rules
10
+ @@rules ||= {}
11
+ end
12
+
13
+ def initialize(task_class)
14
+ @task_class = task_class
15
+ @prev_tasks = []
16
+ @next_tasks = build_task_list
17
+ end
18
+
19
+ def run(env = {})
20
+ @env = env
21
+ while @current_task = @next_tasks.shift
22
+ task = @current_task.new(self)
23
+ info "--> Running #{@current_task}" if @env[:verbose]
24
+ task.run_before(@env)
25
+ task.run(@env)
26
+ @prev_tasks << @current_task
27
+ end
28
+ @env
29
+ end
30
+
31
+ def invoke(task_class)
32
+ pipeline = self.class.new(task_class)
33
+ pipeline.prev_tasks += self.prev_tasks
34
+ pipeline.next_tasks -= self.prev_tasks
35
+ pipeline.run(@env)
36
+ self.next_tasks -= pipeline.prev_tasks
37
+ end
38
+
39
+ def info(message)
40
+ puts message
41
+ end
42
+
43
+ def tsort_each_child(node, &block)
44
+ (self.class.rules[node] || []).each(&block)
45
+ end
46
+
47
+ def tsort_each_node(&block)
48
+ (self.class.rules || {}).each_key(&block)
49
+ end
50
+
51
+ def build_task_list(id_map={}, stack=[])
52
+ arr = []
53
+ each_strongly_connected_component_from(@task_class, id_map, stack) do |t|
54
+ if t.length == 1
55
+ arr << t.first
56
+ else
57
+ raise TSort::Cyclic.new("cyclic dependencies: #{t.inspect}")
58
+ end
59
+ end
60
+ arr
61
+ end
62
+ end
63
+ end
data/lib/hyrb/task.rb ADDED
@@ -0,0 +1,46 @@
1
+ require 'hyrb/pipeline'
2
+
3
+ module Hyrb
4
+ class Task
5
+ extend Forwardable
6
+
7
+ attr_reader :env, :pipeline
8
+
9
+ def_delegators :pipeline, :invoke, :say, :ask, :prompt, :edit, :option_list, :beep, :yes?, :no?
10
+
11
+ def self.depends(*args)
12
+ Commands::Pipeline.rules.merge!({self => args})
13
+ end
14
+
15
+ def self.prompt(hash_name, key, options = {})
16
+ self.prompts << [hash_name, key, options]
17
+ end
18
+
19
+ def self.prompts
20
+ @prompts ||= []
21
+ end
22
+
23
+ def initialize(pipeline)
24
+ @pipeline = pipeline
25
+ end
26
+
27
+ # TODO: run task after
28
+
29
+ def run(env)
30
+ end
31
+
32
+ def run_before(env)
33
+ self.class.prompts.each do |(hash_name, key, options)|
34
+ if options.is_a?(Hash) && options[:default].respond_to?(:call)
35
+ options[:default] = options[:default].call(env)
36
+ end
37
+
38
+ prompt "Please enter #{key}", env[hash_name], key, options
39
+ end
40
+ end
41
+ end
42
+
43
+ module Tasks
44
+ Hyrb.autoload_each self, "hyrb/tasks", %w( ansible creds defaults developers digital_ocean environment github google hipchat project provision rackspace )
45
+ end
46
+ end
@@ -0,0 +1,96 @@
1
+ require 'yaml'
2
+ require 'erb'
3
+
4
+ module Hyrb
5
+ module Tasks
6
+ module Ansible
7
+ class Inject < Task
8
+ depends Environment::SetupExisting
9
+
10
+ def run(env)
11
+
12
+ end
13
+ end
14
+
15
+ class Init < Task
16
+ depends Environment::SetupExisting
17
+
18
+ def run(env)
19
+ end
20
+ end
21
+
22
+ class CreatePlaybookDir < Task
23
+ depends Init
24
+
25
+ def run(env)
26
+ # How to deal with enviornment vs project for ansible
27
+ if File.directory? env.project.ansible_path
28
+ say "Ansible dir exists in #{env.project.ansible_path}", :yellow
29
+ else
30
+ FileUtils.mkdir_p(env.project.ansible_path)
31
+ FileUtils.cp_r("#{TEMPLATE_PATH}/ansible/roles", "#{env.project.ansible_path}/roles")
32
+ say "Created ansible dir in #{env.project.ansible_path}", :green
33
+ end
34
+ end
35
+ end
36
+
37
+ class DestroyPlaybookDir < Task
38
+ depends Init
39
+
40
+ def run(env)
41
+ end
42
+ end
43
+
44
+ class CreatePlaybook < Task
45
+ depends CreatePlaybookDir, Environment::Deployment
46
+
47
+ def run(env)
48
+ host = Models::AnsibleHost.new(env.project, env.environment)
49
+ host.save!
50
+ say "Saved inventory file to #{host.filepath}", :green
51
+
52
+ site_playbook = Models::AnsibleSite.new(env.project, env.environment)
53
+ site_playbook.data.merge!({
54
+ project_name: env.project.name,
55
+ host: env.environment.host,
56
+ deploy_user: env.environment.deploy_user,
57
+ base_path: env.environment.base_path,
58
+ domain: env.environment.domain,
59
+ relative_web_root: env.environment.relative_web_root,
60
+ })
61
+
62
+ site_playbook.hosts = env.environment.label
63
+
64
+ if ! site_playbook.mysql_host && yes?("Does the project use a SQL database?")
65
+ invoke Environment::Database
66
+
67
+ site_playbook.data.merge!({
68
+ mysql_host: env.environment.database_host,
69
+ mysql_db: env.environment.database_name,
70
+ mysql_user: env.environment.database_user,
71
+ mysql_password: env.environment.database_password,
72
+ })
73
+
74
+ # TODO: add mysql role to playbook
75
+
76
+ site_playbook.vars_prompt = [{
77
+ "name" => "mysql_root_user",
78
+ "prompt" => "MySQL Root User",
79
+ "private" => false
80
+ },{
81
+ "name" => "mysql_root_password",
82
+ "prompt" => "MySQL Root Password"
83
+ }]
84
+ end
85
+
86
+ site_playbook.save!
87
+
88
+ say "Saved site playbook to #{site_playbook.filepath}", :green
89
+ say "Run it with", :green
90
+ say "\tcd #{env.project.ansible_path}", :green
91
+ say "\tansible-playbook -i $(which yaminv) #{env.environment.label}.yml", :green
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,39 @@
1
+ module Hyrb
2
+ module Tasks
3
+ module Creds
4
+ class Inject < Task
5
+ depends Defaults::Init
6
+
7
+ def run(env)
8
+ env.creds = Hyrb::Models::Creds.new
9
+ end
10
+ end
11
+
12
+ class Init < Task
13
+ depends Inject
14
+ end
15
+
16
+ class Show < Task
17
+ depends Init
18
+
19
+ def run(env)
20
+ say env.creds.data.to_hash.to_yaml
21
+ end
22
+ end
23
+
24
+ class Build < Task
25
+ depends Init
26
+
27
+ Hyrb::Models::Creds.keys.each { |key| prompt :creds, key, always: true }
28
+ end
29
+
30
+ class Edit < Task
31
+ depends Init
32
+
33
+ def run(env)
34
+ edit(env.creds.filepath)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,43 @@
1
+ module Hyrb
2
+ module Tasks
3
+ module Defaults
4
+ class Inject < Task
5
+ def run(env)
6
+ env.defaults = Hyrb::Models::Defaults.new
7
+ end
8
+ end
9
+
10
+ class Init < Task
11
+ depends Inject
12
+
13
+ prompt :defaults, :domain, "hyfnrsx1.com"
14
+ end
15
+
16
+ class Show < Task
17
+ depends Defaults::Init
18
+
19
+ def run(env)
20
+ say env.defaults.data.to_hash.to_yaml
21
+ end
22
+ end
23
+
24
+ class Build < Task
25
+ depends Defaults::Init
26
+
27
+ Hyrb::Models::Defaults.keys.each { |key| prompt :defaults, key, always: true }
28
+
29
+ def run(env)
30
+ say "Credentials updated", :green
31
+ end
32
+ end
33
+
34
+ class Edit < Task
35
+ depends Defaults::Init
36
+
37
+ def run(env)
38
+ edit(env.defaults.filepath)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,120 @@
1
+ require 'faraday'
2
+ require 'uri'
3
+ require 'google_doc_seed'
4
+ require 'json'
5
+ require 'csv'
6
+
7
+ module Hyrb
8
+ module Tasks
9
+ module Developers
10
+ class Inject < Task
11
+ def run(env)
12
+ env.developers = Hyrb::Models::Cache::Developers.new.data
13
+ end
14
+ end
15
+
16
+ class Init < Task
17
+ depends Inject
18
+
19
+ def run(env)
20
+ invoke(Download) if env.developers.blank?
21
+ end
22
+ end
23
+
24
+ class Show < Task
25
+ depends Init
26
+
27
+ def run(env)
28
+ say env.developers.map(&:to_hash).to_yaml
29
+ end
30
+ end
31
+
32
+ class Download < Task
33
+ depends Google::Init
34
+
35
+ def run(env)
36
+ response = Faraday.post "https://accounts.google.com/o/oauth2/token",
37
+ refresh_token: env.creds.google_refresh_token,
38
+ client_id: env.creds.google_client_id,
39
+ client_secret: env.creds.google_client_secret,
40
+ grant_type: "refresh_token"
41
+
42
+ access_token = JSON.parse(response.body)['access_token']
43
+ seeder = GoogleDocSeed.new(access_token)
44
+ csv_string = seeder.to_csv_string(env.creds.google_spreadsheet_key)
45
+
46
+ csv = CSV.parse(csv_string, GoogleCSVConverters::CSV_SETTINGS)
47
+
48
+ env.developers = Hyrb::Models::Cache::Developers.new
49
+ env.developers.data = csv.map do |row|
50
+ pks = []
51
+ while true
52
+ field = row.delete(:public_key)
53
+ if field.length > 1
54
+ pks << field[1] if field[1]
55
+ else
56
+ break
57
+ end
58
+ end
59
+
60
+ Hyrb::Models::Developer.new({
61
+ name: row[:name],
62
+ email: row[:email],
63
+ role: Hyrb::Models::Developer::ROLE_MAP[row[:role].to_i],
64
+ github_username: row[:github],
65
+ keys: pks,
66
+ }) if row[:role].to_i < 4 && row[:email]
67
+ end.compact
68
+
69
+ env.developers.save!
70
+ say "Developer data downloaded", :green
71
+ end
72
+ end
73
+
74
+ class AddToProject < Task
75
+ depends Init, Project::Init
76
+
77
+ def run(env)
78
+ added_devs = if env.project.users.try(:any?)
79
+ env.developers.select {|dev| env.project.users.include? dev.email }
80
+ else
81
+ env.developers.select {|dev| dev.role == :admin }
82
+ end
83
+
84
+ loop do
85
+ say "Devs with access"
86
+ list_devs(added_devs)
87
+
88
+ break unless yes? "Add more devs?"
89
+
90
+ dev_list = env.developers - added_devs
91
+ devs = *prompt_for_dev(dev_list)
92
+ added_devs += devs
93
+ end
94
+
95
+ env.project.users = added_devs.map(&:email)
96
+ env.project.save!
97
+ end
98
+
99
+ def list_devs(devs)
100
+ devs.each {|d| say "#{d.name} <#{d.email}>"}
101
+ end
102
+
103
+ def prompt_for_dev(devs)
104
+ option_list(devs) { |d, i| "#{i+1}: #{d.name} <#{d.email}>" }
105
+ end
106
+ end
107
+
108
+ module GoogleCSVConverters
109
+ BLANK_TO_NIL = ->(f) { f.empty? ? nil : f }
110
+ TRIM_WHITESPACE = ->(f) { f.respond_to?(:gsub) ? f.gsub(/\s+$/, '') : f }
111
+
112
+ CSV_SETTINGS = {
113
+ headers: true,
114
+ header_converters: :symbol,
115
+ converters: [BLANK_TO_NIL, TRIM_WHITESPACE],
116
+ }
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,84 @@
1
+ require 'fog/digitalocean'
2
+ require 'parallel'
3
+
4
+ module Hyrb
5
+ module Tasks
6
+ module DigitalOcean
7
+ class Inject < Task
8
+ depends Creds::Init
9
+
10
+ prompt :creds, :digital_ocean_api_key
11
+ prompt :creds, :digital_ocean_client_id
12
+
13
+ def run(env)
14
+ env.digital_ocean_client = Fog::Compute.new({
15
+ provider: 'DigitalOcean',
16
+ digitalocean_api_key: env.creds.digital_ocean_api_key,
17
+ digitalocean_client_id: env.creds.digital_ocean_client_id,
18
+ })
19
+ end
20
+ end
21
+
22
+ class Init < Task
23
+ depends Inject
24
+
25
+ def run(env)
26
+ # TODO: allow cache referesh to be forced
27
+ env.digital_ocean_cache = Hyrb::Models::Cache::DigitalOcean.new
28
+ env.digital_ocean_cache.class.keys.each do |key|
29
+ env.digital_ocean_cache[key] ||= env.digital_ocean_client.send(key).map(&:attributes)
30
+ end
31
+ env.digital_ocean_cache.save!
32
+ end
33
+ end
34
+
35
+ class SSHKeys < Task
36
+ depends Init, Developers::Init
37
+
38
+ def run(env)
39
+ env.digital_ocean_ssh_keys = Parallel.map(env.digital_ocean_client.ssh_keys.all, in_threads: 12) do |dokey|
40
+ env.digital_ocean_client.ssh_keys.get(dokey.id)
41
+ end
42
+
43
+ env.digital_ocean_ssh_keys.each do |k|
44
+ dev = env.developers.find { |dev| dev.keys.include? k.ssh_pub_key }
45
+ dev.digital_ocean_id = k.id if dev
46
+ end
47
+ end
48
+ end
49
+
50
+ class ShowSSHKeys < Task
51
+ depends SSHKeys
52
+
53
+ def run(env)
54
+ say env.digital_ocean_ssh_keys.map { |k| {name: k.name, id: k.id, ssh_key: k.ssh_pub_key} }.to_yaml
55
+ end
56
+ end
57
+
58
+ class SyncSSHKeys < Task
59
+ depends SSHKeys
60
+
61
+ def run(env)
62
+ env.digital_ocean_ssh_keys.each do |dokey|
63
+ unless key = env.developers.any? { |dev| dev.keys.include? dokey.ssh_pub_key }
64
+ say "Removing #{dokey.name} SSH key from Digital Ocean", :green
65
+ env.digital_ocean_client.ssh_keys.destroy(dokey.id)
66
+ end
67
+ end
68
+
69
+ env.developers.select { |d| d.keys.any? }.each do |dev|
70
+ if key = env.digital_ocean_ssh_keys.any? { |k| dev.keys.include? k.ssh_pub_key }
71
+ say "#{dev.name}'s keys already added to Digital Ocean", :yellow
72
+ else
73
+ dev.keys.each do |pubkey, i|
74
+ say "Adding pubkey for #{dev.name} to Digital Ocean", :green
75
+ env.digital_ocean_client.ssh_keys.create(name: dev.name, ssh_pub_key: pubkey)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ end
83
+ end
84
+ end