beaker 0.0.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.
Files changed (88) hide show
  1. checksums.yaml +15 -0
  2. data/.gitignore +17 -0
  3. data/.rspec +2 -0
  4. data/.simplecov +14 -0
  5. data/DOCUMENTING.md +167 -0
  6. data/Gemfile +3 -0
  7. data/LICENSE +17 -0
  8. data/README.md +332 -0
  9. data/Rakefile +121 -0
  10. data/beaker.gemspec +42 -0
  11. data/beaker.rb +10 -0
  12. data/bin/beaker +9 -0
  13. data/lib/beaker.rb +36 -0
  14. data/lib/beaker/answers.rb +29 -0
  15. data/lib/beaker/answers/version28.rb +104 -0
  16. data/lib/beaker/answers/version30.rb +194 -0
  17. data/lib/beaker/cli.rb +113 -0
  18. data/lib/beaker/command.rb +241 -0
  19. data/lib/beaker/command_factory.rb +21 -0
  20. data/lib/beaker/dsl.rb +85 -0
  21. data/lib/beaker/dsl/assertions.rb +87 -0
  22. data/lib/beaker/dsl/helpers.rb +625 -0
  23. data/lib/beaker/dsl/install_utils.rb +299 -0
  24. data/lib/beaker/dsl/outcomes.rb +99 -0
  25. data/lib/beaker/dsl/roles.rb +97 -0
  26. data/lib/beaker/dsl/structure.rb +63 -0
  27. data/lib/beaker/dsl/wrappers.rb +100 -0
  28. data/lib/beaker/host.rb +193 -0
  29. data/lib/beaker/host/aix.rb +15 -0
  30. data/lib/beaker/host/aix/file.rb +16 -0
  31. data/lib/beaker/host/aix/group.rb +35 -0
  32. data/lib/beaker/host/aix/user.rb +32 -0
  33. data/lib/beaker/host/unix.rb +54 -0
  34. data/lib/beaker/host/unix/exec.rb +15 -0
  35. data/lib/beaker/host/unix/file.rb +16 -0
  36. data/lib/beaker/host/unix/group.rb +40 -0
  37. data/lib/beaker/host/unix/pkg.rb +22 -0
  38. data/lib/beaker/host/unix/user.rb +32 -0
  39. data/lib/beaker/host/windows.rb +44 -0
  40. data/lib/beaker/host/windows/exec.rb +18 -0
  41. data/lib/beaker/host/windows/file.rb +15 -0
  42. data/lib/beaker/host/windows/group.rb +36 -0
  43. data/lib/beaker/host/windows/pkg.rb +26 -0
  44. data/lib/beaker/host/windows/user.rb +32 -0
  45. data/lib/beaker/hypervisor.rb +37 -0
  46. data/lib/beaker/hypervisor/aixer.rb +52 -0
  47. data/lib/beaker/hypervisor/blimper.rb +123 -0
  48. data/lib/beaker/hypervisor/fusion.rb +56 -0
  49. data/lib/beaker/hypervisor/solaris.rb +65 -0
  50. data/lib/beaker/hypervisor/vagrant.rb +118 -0
  51. data/lib/beaker/hypervisor/vcloud.rb +175 -0
  52. data/lib/beaker/hypervisor/vsphere.rb +80 -0
  53. data/lib/beaker/hypervisor/vsphere_helper.rb +200 -0
  54. data/lib/beaker/logger.rb +167 -0
  55. data/lib/beaker/network_manager.rb +73 -0
  56. data/lib/beaker/options_parsing.rb +323 -0
  57. data/lib/beaker/result.rb +55 -0
  58. data/lib/beaker/shared.rb +15 -0
  59. data/lib/beaker/shared/error_handler.rb +17 -0
  60. data/lib/beaker/shared/host_handler.rb +46 -0
  61. data/lib/beaker/shared/repetition.rb +28 -0
  62. data/lib/beaker/ssh_connection.rb +198 -0
  63. data/lib/beaker/test_case.rb +225 -0
  64. data/lib/beaker/test_config.rb +148 -0
  65. data/lib/beaker/test_suite.rb +288 -0
  66. data/lib/beaker/utils.rb +7 -0
  67. data/lib/beaker/utils/ntp_control.rb +42 -0
  68. data/lib/beaker/utils/repo_control.rb +92 -0
  69. data/lib/beaker/utils/setup_helper.rb +77 -0
  70. data/lib/beaker/utils/validator.rb +27 -0
  71. data/spec/beaker/command_spec.rb +94 -0
  72. data/spec/beaker/dsl/assertions_spec.rb +104 -0
  73. data/spec/beaker/dsl/helpers_spec.rb +230 -0
  74. data/spec/beaker/dsl/install_utils_spec.rb +70 -0
  75. data/spec/beaker/dsl/outcomes_spec.rb +43 -0
  76. data/spec/beaker/dsl/roles_spec.rb +86 -0
  77. data/spec/beaker/dsl/structure_spec.rb +60 -0
  78. data/spec/beaker/dsl/wrappers_spec.rb +52 -0
  79. data/spec/beaker/host_spec.rb +95 -0
  80. data/spec/beaker/logger_spec.rb +117 -0
  81. data/spec/beaker/options_parsing_spec.rb +37 -0
  82. data/spec/beaker/puppet_command_spec.rb +128 -0
  83. data/spec/beaker/ssh_connection_spec.rb +39 -0
  84. data/spec/beaker/test_case_spec.rb +6 -0
  85. data/spec/beaker/test_suite_spec.rb +44 -0
  86. data/spec/mocks_and_helpers.rb +34 -0
  87. data/spec/spec_helper.rb +15 -0
  88. metadata +359 -0
@@ -0,0 +1,80 @@
1
+ module Beaker
2
+ class Vsphere < Beaker::Hypervisor
3
+
4
+ def initialize(vsphere_hosts, options, config)
5
+ @options = options
6
+ @@config = config['CONFIG'].dup
7
+ @logger = options[:logger]
8
+ @vsphere_hosts = vsphere_hosts
9
+ require 'yaml' unless defined?(YAML)
10
+ vsphere_credentials = VsphereHelper.load_config
11
+
12
+ @logger.notify "Connecting to vSphere at #{vsphere_credentials[:server]}" +
13
+ " with credentials for #{vsphere_credentials[:user]}"
14
+
15
+ vsphere_helper = VsphereHelper.new( vsphere_credentials )
16
+
17
+ vsphere_vms = {}
18
+ @vsphere_hosts.each do |h|
19
+ name = h["vmname"] || h.name
20
+ vsphere_vms[name] = h["snapshot"]
21
+ end
22
+ vms = vsphere_helper.find_vms(vsphere_vms.keys)
23
+ vsphere_vms.each_pair do |name, snap|
24
+ unless vm = vms[name]
25
+ raise "Couldn't find VM #{name} in vSphere!"
26
+ end
27
+
28
+ snapshot = vsphere_helper.find_snapshot(vm, snap) or
29
+ raise "Could not find snapshot '#{snap}' for VM #{vm.name}!"
30
+
31
+ @logger.notify "Reverting #{vm.name} to snapshot '#{snap}'"
32
+ start = Time.now
33
+ # This will block for each snapshot...
34
+ # The code to issue them all and then wait until they are all done sucks
35
+ snapshot.RevertToSnapshot_Task.wait_for_completion
36
+
37
+ time = Time.now - start
38
+ @logger.notify "Spent %.2f seconds reverting" % time
39
+
40
+ unless vm.runtime.powerState == "poweredOn"
41
+ @logger.notify "Booting #{vm.name}"
42
+ start = Time.now
43
+ vm.PowerOnVM_Task.wait_for_completion
44
+ @logger.notify "Spent %.2f seconds booting #{vm.name}" % (Time.now - start)
45
+ end
46
+ end
47
+
48
+ vsphere_helper.close
49
+ end
50
+
51
+ def cleanup
52
+ @logger.notify "Destroying vsphere boxes"
53
+ vsphere_credentials = VsphereHelper.load_config
54
+
55
+ @logger.notify "Connecting to vSphere at #{vsphere_credentials[:server]}" +
56
+ " with credentials for #{vsphere_credentials[:user]}"
57
+
58
+ vsphere_helper = VsphereHelper.new( vsphere_credentials )
59
+
60
+ vm_names = @vsphere_hosts.map {|h| h['vmname'] || h.name }
61
+ vms = vsphere_helper.find_vms vm_names
62
+ vm_names.each do |name|
63
+ unless vm = vms[name]
64
+ raise "Couldn't find VM #{name} in vSphere!"
65
+ end
66
+
67
+ if vm.runtime.powerState == "poweredOn"
68
+ @logger.notify "Shutting down #{vm.name}"
69
+ start = Time.now
70
+ vm.PowerOffVM_Task.wait_for_completion
71
+ @logger.notify(
72
+ "Spent %.2f seconds halting #{vm.name}" % (Time.now - start) )
73
+ end
74
+ end
75
+
76
+ vsphere_helper.close
77
+ end
78
+
79
+ end
80
+ end
@@ -0,0 +1,200 @@
1
+ require 'yaml' unless defined?(YAML)
2
+ require 'rubygems' unless defined?(Gem)
3
+ begin
4
+ require 'beaker/logger'
5
+ rescue LoadError
6
+ require File.expand_path(File.join(File.dirname(__FILE__), '..', 'logger.rb'))
7
+ end
8
+
9
+ class VsphereHelper
10
+ def initialize vInfo = {}
11
+ @logger = vInfo[:logger] || Beaker::Logger.new
12
+ begin
13
+ require 'rbvmomi'
14
+ rescue LoadError
15
+ raise "Unable to load RbVmomi, please ensure its installed"
16
+ end
17
+ @connection = RbVmomi::VIM.connect :host => vInfo[:server],
18
+ :user => vInfo[:user],
19
+ :password => vInfo[:pass],
20
+ :insecure => true
21
+ end
22
+
23
+ def self.load_config
24
+ # support Fog/Cloud Provisioner layout
25
+ # (ie, someplace besides my made up conf)
26
+ vsphere_credentials = nil
27
+ if File.exists? '/etc/plharness/vsphere'
28
+ vsphere_credentials = load_legacy_credentials
29
+
30
+ elsif File.exists?( File.join(ENV['HOME'], '.fog') )
31
+ vsphere_credentials = load_fog_credentials
32
+ end
33
+
34
+ return vsphere_credentials
35
+ end
36
+
37
+ def self.load_fog_credentials
38
+ vInfo = YAML.load_file( File.join(ENV['HOME'], '.fog') )
39
+
40
+ vsphere_credentials = {}
41
+ vsphere_credentials[:server] = vInfo[:default][:vsphere_server]
42
+ vsphere_credentials[:user] = vInfo[:default][:vsphere_username]
43
+ vsphere_credentials[:pass] = vInfo[:default][:vsphere_password]
44
+
45
+ return vsphere_credentials
46
+ end
47
+
48
+ def self.load_legacy_credentials
49
+ vInfo = YAML.load_file '/etc/plharness/vsphere'
50
+
51
+ puts(
52
+ "Use of /etc/plharness/vsphere as a config file is deprecated.\n" +
53
+ "Please use ~/.fog instead\n" +
54
+ "See http://docs.puppetlabs.com/pe/2.0/" +
55
+ "cloudprovisioner_configuring.html for format"
56
+ )
57
+
58
+ vsphere_credentials = {}
59
+ vsphere_credentials[:server] = vInfo['location']
60
+ vsphere_credentials[:user] = vInfo['user']
61
+ vsphere_credentials[:pass] = vInfo['pass']
62
+
63
+ return vsphere_credentials
64
+ end
65
+
66
+ def find_snapshot vm, snapname
67
+ search_child_snaps vm.snapshot.rootSnapshotList, snapname
68
+ end
69
+
70
+ def search_child_snaps tree, snapname
71
+ snapshot = nil
72
+ tree.each do |child|
73
+ if child.name == snapname
74
+ snapshot ||= child.snapshot
75
+ else
76
+ snapshot ||= search_child_snaps child.childSnapshotList, snapname
77
+ end
78
+ end
79
+ snapshot
80
+ end
81
+
82
+ def find_customization name
83
+ csm = @connection.serviceContent.customizationSpecManager
84
+
85
+ begin
86
+ customizationSpec = csm.GetCustomizationSpec({:name => name}).spec
87
+ rescue
88
+ customizationSpec = nil
89
+ end
90
+
91
+ return customizationSpec
92
+ end
93
+
94
+ # an easier wrapper around the horrid PropertyCollector interface,
95
+ # necessary for searching VMs in all Datacenters that may be nested
96
+ # within folders of arbitrary depth
97
+ # returns a hash array of <name> => <VirtualMachine ManagedObjects>
98
+ def find_vms names, connection = @connection
99
+ names = names.is_a?(Array) ? names : [ names ]
100
+ containerView = get_base_vm_container_from connection
101
+ propertyCollector = connection.propertyCollector
102
+
103
+ objectSet = [{
104
+ :obj => containerView,
105
+ :skip => true,
106
+ :selectSet => [ RbVmomi::VIM::TraversalSpec.new({
107
+ :name => 'gettingTheVMs',
108
+ :path => 'view',
109
+ :skip => false,
110
+ :type => 'ContainerView'
111
+ }) ]
112
+ }]
113
+
114
+ propSet = [{
115
+ :pathSet => [ 'name' ],
116
+ :type => 'VirtualMachine'
117
+ }]
118
+
119
+ results = propertyCollector.RetrievePropertiesEx({
120
+ :specSet => [{
121
+ :objectSet => objectSet,
122
+ :propSet => propSet
123
+ }],
124
+ :options => { :maxObjects => nil }
125
+ })
126
+
127
+ vms = {}
128
+ results.objects.each do |result|
129
+ name = result.propSet.first.val
130
+ next unless names.include? name
131
+ vms[name] = result.obj
132
+ end
133
+
134
+ while results.token do
135
+ results = propertyCollector.ContinueRetrievePropertiesEx({:token => results.token})
136
+ results.objects.each do |result|
137
+ name = result.propSet.first.val
138
+ next unless names.include? name
139
+ vms[name] = result.obj
140
+ end
141
+ end
142
+ vms
143
+ end
144
+
145
+ def find_datastore datastorename
146
+ datacenter = @connection.serviceInstance.find_datacenter
147
+ datacenter.find_datastore(datastorename)
148
+ end
149
+
150
+ def find_folder foldername
151
+ datacenter = @connection.serviceInstance.find_datacenter
152
+ base = datacenter.vmFolder
153
+ folders = foldername.split('/')
154
+ folders.each do |folder|
155
+ case base
156
+ when RbVmomi::VIM::Folder
157
+ base = base.childEntity.find { |f| f.name == folder }
158
+ else
159
+ abort "Unexpected object type encountered (#{base.class}) while finding folder"
160
+ end
161
+ end
162
+
163
+ base
164
+ end
165
+
166
+ def find_pool poolname
167
+ datacenter = @connection.serviceInstance.find_datacenter
168
+ base = datacenter.hostFolder
169
+ pools = poolname.split('/')
170
+ pools.each do |pool|
171
+ case base
172
+ when RbVmomi::VIM::Folder
173
+ base = base.childEntity.find { |f| f.name == pool }
174
+ when RbVmomi::VIM::ClusterComputeResource
175
+ base = base.resourcePool.resourcePool.find { |f| f.name == pool }
176
+ when RbVmomi::VIM::ResourcePool
177
+ base = base.resourcePool.find { |f| f.name == pool }
178
+ else
179
+ abort "Unexpected object type encountered (#{base.class}) while finding resource pool"
180
+ end
181
+ end
182
+
183
+ base = base.resourcePool unless base.is_a?(RbVmomi::VIM::ResourcePool) and base.respond_to?(:resourcePool)
184
+ base
185
+ end
186
+
187
+ def get_base_vm_container_from connection
188
+ viewManager = connection.serviceContent.viewManager
189
+ viewManager.CreateContainerView({
190
+ :container => connection.serviceContent.rootFolder,
191
+ :recursive => true,
192
+ :type => [ 'VirtualMachine' ]
193
+ })
194
+ end
195
+
196
+ def close
197
+ @connection.close
198
+ end
199
+ end
200
+
@@ -0,0 +1,167 @@
1
+ module Beaker
2
+ class Logger
3
+ NORMAL = "\e[00;00m"
4
+ BRIGHT_NORMAL = "\e[00;01m"
5
+ BLACK = "\e[00;30m"
6
+ RED = "\e[00;31m"
7
+ GREEN = "\e[00;32m"
8
+ YELLOW = "\e[00;33m"
9
+ BLUE = "\e[00;34m"
10
+ MAGENTA = "\e[00;35m"
11
+ CYAN = "\e[00;36m"
12
+ WHITE = "\e[00;37m"
13
+ GREY = "\e[01;30m"
14
+ BRIGHT_RED = "\e[01;31m"
15
+ BRIGHT_GREEN = "\e[01;32m"
16
+ BRIGHT_YELLOW = "\e[01;33m"
17
+ BRIGHT_BLUE = "\e[01;34m"
18
+ BRIGHT_MAGENTA = "\e[01;35m"
19
+ BRIGHT_CYAN = "\e[01;36m"
20
+ BRIGHT_WHITE = "\e[01;37m"
21
+
22
+ LOG_LEVELS = {
23
+ :debug => 1,
24
+ :warn => 2,
25
+ :normal => 3,
26
+ :info => 4
27
+ }
28
+
29
+ attr_accessor :color, :log_level, :destinations
30
+
31
+ def initialize(*args)
32
+ options = args.last.is_a?(Hash) ? args.pop : {}
33
+ @color = options[:color]
34
+ @log_level = options[:debug] ? :debug : :normal
35
+ @destinations = []
36
+
37
+ dests = args
38
+ dests << STDOUT unless options[:quiet]
39
+ dests.uniq!
40
+ dests.each {|dest| add_destination(dest)}
41
+ end
42
+
43
+ def add_destination(dest)
44
+ case dest
45
+ when IO
46
+ @destinations << dest
47
+ when String
48
+ @destinations << File.open(dest, 'w')
49
+ else
50
+ raise "Unsuitable log destination #{dest.inspect}"
51
+ end
52
+ end
53
+
54
+ def remove_destination(dest)
55
+ case dest
56
+ when IO
57
+ @destinations.delete(dest)
58
+ when String
59
+ @destinations.delete_if {|d| d.respond_to?(:path) and d.path == dest}
60
+ else
61
+ raise "Unsuitable log destination #{dest.inspect}"
62
+ end
63
+ end
64
+
65
+ def is_debug?
66
+ LOG_LEVELS[@log_level] <= LOG_LEVELS[:debug]
67
+ end
68
+
69
+ def is_warn?
70
+ LOG_LEVELS[@log_level] <= LOG_LEVELS[:warn]
71
+ end
72
+
73
+ def host_output *args
74
+ return unless is_debug?
75
+ strings = strip_colors_from args
76
+ string = strings.join
77
+ optionally_color GREY, string, false
78
+ end
79
+
80
+ def debug *args
81
+ return unless is_debug?
82
+ optionally_color WHITE, args
83
+ end
84
+
85
+ def warn *args
86
+ return unless is_warn?
87
+ strings = args.map {|msg| "Warning: #{msg}" }
88
+ optionally_color YELLOW, strings
89
+ end
90
+
91
+ def success *args
92
+ optionally_color GREEN, args
93
+ end
94
+
95
+ def notify *args
96
+ optionally_color BRIGHT_WHITE, args
97
+ end
98
+
99
+ def error *args
100
+ optionally_color BRIGHT_RED, args
101
+ end
102
+
103
+ def strip_colors_from lines
104
+ Array(lines).map do |line|
105
+ line.gsub /\e\[(\d+;)?\d+m/, ''
106
+ end
107
+ end
108
+
109
+ def optionally_color color_code, msg, add_newline = true
110
+ print_statement = add_newline ? :puts : :print
111
+ @destinations.each do |to|
112
+ to.print color_code if @color
113
+ to.send print_statement, msg
114
+ to.print NORMAL if @color
115
+ end
116
+ end
117
+
118
+ # utility method to get the current call stack and format it
119
+ # to a human-readable string (which some IDEs/editors
120
+ # will recognize as links to the line numbers in the trace)
121
+ def pretty_backtrace backtrace = caller(1)
122
+ trace = purge_harness_files_from( Array( backtrace ) )
123
+ expand_symlinks( trace ).join "\n"
124
+ end
125
+
126
+ private
127
+ def expand_symlinks backtrace
128
+ backtrace.collect do |line|
129
+ file_path, line_num = line.split( ":" )
130
+ expanded_path = expand_symlink File.expand_path( file_path )
131
+ expanded_path.to_s + ":" + line_num.to_s
132
+ end
133
+ end
134
+
135
+ def purge_harness_files_from backtrace
136
+ mostly_purged = backtrace.reject do |line|
137
+ # LOADED_FEATURES is an array of anything `require`d, i.e. everything
138
+ # but the test in question
139
+ $LOADED_FEATURES.any? do |require_path|
140
+ line.include? require_path
141
+ end
142
+ end
143
+
144
+ # And remove lines that contain our program name in them
145
+ completely_purged = mostly_purged.reject {|line| line.include? $0 }
146
+ end
147
+
148
+ # utility method that takes a path as input, checks each component
149
+ # of the path to see if it is a symlink, and expands
150
+ # it if it is. returns the expanded path.
151
+ def expand_symlink file_path
152
+ file_path.split( "/" ).inject do |full_path, next_dir|
153
+ next_path = full_path + "/" + next_dir
154
+ if File.symlink? next_path
155
+ link = File.readlink next_path
156
+ next_path =
157
+ case link
158
+ when /^\// then link
159
+ else
160
+ File.expand_path( full_path + "/" + link )
161
+ end
162
+ end
163
+ next_path
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,73 @@
1
+ %w(hypervisor).each do |lib|
2
+ begin
3
+ require "beaker/#{lib}"
4
+ rescue LoadError
5
+ require File.expand_path(File.join(File.dirname(__FILE__), lib))
6
+ end
7
+ end
8
+
9
+ module Beaker
10
+ class NetworkManager
11
+ HYPERVISOR_TYPES = ['solaris', 'blimpy', 'vsphere', 'fusion', 'aix', 'vcloud', 'vagrant']
12
+
13
+ def initialize(config, options, logger)
14
+ @logger = logger
15
+ @options = options
16
+ @hosts = []
17
+ @config = config
18
+ @virtual_machines = {}
19
+ @noprovision_machines = []
20
+ @config['HOSTS'].each_key do |name|
21
+ host_info = @config['HOSTS'][name]
22
+ #check to see if this host has a hypervisor
23
+ hypervisor = host_info['hypervisor']
24
+ #provision this box
25
+ # - only if we are running with --provision
26
+ # - only if we have a hypervisor
27
+ # - only if either the specific hosts has no specification or has 'provision' in its config
28
+ if @options[:provision] && hypervisor && (host_info.has_key?('provision') ? host_info['provision'] : true) #obey config file provision, defaults to provisioning vms
29
+ raise "Invalid hypervisor: #{hypervisor} (#{name})" unless HYPERVISOR_TYPES.include? hypervisor
30
+ @logger.debug "Hypervisor for #{name} is #{host_info['hypervisor'] || 'default' }, and I'm going to use #{hypervisor}"
31
+ @virtual_machines[hypervisor] = [] unless @virtual_machines[hypervisor]
32
+ @virtual_machines[hypervisor] << name
33
+ else #this is a non-provisioned machine, deal with it without hypervisors
34
+ @logger.debug "No hypervisor for #{name}, connecting to host without provisioning"
35
+ @noprovision_machines << name
36
+ end
37
+
38
+ end
39
+ end
40
+
41
+ def provision
42
+ @provisioned_set = {}
43
+ @virtual_machines.each do |type, names|
44
+ hosts_for_type = []
45
+ #set up host objects for provisioned provisioned_set
46
+ names.each do |name|
47
+ host = Beaker::Host.create(name, @options, @config)
48
+ hosts_for_type << host
49
+ end
50
+ @provisioned_set[type] = Beaker::Hypervisor.create(type, hosts_for_type, @options, @config)
51
+ @hosts << hosts_for_type
52
+ end
53
+ @noprovision_machines.each do |name|
54
+ @hosts << Beaker::Host.create(name, @options, @config)
55
+ end
56
+ @hosts = @hosts.flatten
57
+ @hosts
58
+ end
59
+
60
+ def cleanup
61
+ #only cleanup if we aren't preserving hosts
62
+ #shut down connections
63
+ @hosts.each {|host| host.close }
64
+
65
+ if not @options[:preserve_hosts]
66
+ @provisioned_set.each_key do |type|
67
+ @provisioned_set[type].cleanup
68
+ end
69
+ end
70
+ end
71
+
72
+ end
73
+ end