longleaf 0.1.0 → 0.2.0.pre.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.editorconfig +13 -0
  3. data/.gitignore +1 -0
  4. data/.rubocop.yml +4 -0
  5. data/.rubocop_todo.yml +755 -0
  6. data/README.md +29 -7
  7. data/lib/longleaf/candidates/file_selector.rb +107 -0
  8. data/lib/longleaf/candidates/service_candidate_filesystem_iterator.rb +99 -0
  9. data/lib/longleaf/candidates/service_candidate_locator.rb +18 -0
  10. data/lib/longleaf/cli.rb +102 -6
  11. data/lib/longleaf/commands/deregister_command.rb +50 -0
  12. data/lib/longleaf/commands/preserve_command.rb +45 -0
  13. data/lib/longleaf/commands/register_command.rb +24 -38
  14. data/lib/longleaf/commands/validate_config_command.rb +6 -2
  15. data/lib/longleaf/commands/validate_metadata_command.rb +49 -0
  16. data/lib/longleaf/errors.rb +19 -0
  17. data/lib/longleaf/events/deregister_event.rb +55 -0
  18. data/lib/longleaf/events/event_names.rb +9 -0
  19. data/lib/longleaf/events/event_status_tracking.rb +59 -0
  20. data/lib/longleaf/events/preserve_event.rb +71 -0
  21. data/lib/longleaf/events/register_event.rb +37 -26
  22. data/lib/longleaf/helpers/digest_helper.rb +50 -0
  23. data/lib/longleaf/helpers/service_date_helper.rb +51 -0
  24. data/lib/longleaf/logging.rb +1 -0
  25. data/lib/longleaf/logging/redirecting_logger.rb +9 -8
  26. data/lib/longleaf/models/app_fields.rb +2 -0
  27. data/lib/longleaf/models/file_record.rb +8 -3
  28. data/lib/longleaf/models/md_fields.rb +1 -0
  29. data/lib/longleaf/models/metadata_record.rb +16 -4
  30. data/lib/longleaf/models/service_definition.rb +4 -3
  31. data/lib/longleaf/models/service_fields.rb +2 -0
  32. data/lib/longleaf/models/service_record.rb +4 -1
  33. data/lib/longleaf/models/storage_location.rb +18 -1
  34. data/lib/longleaf/preservation_services/fixity_check_service.rb +121 -0
  35. data/lib/longleaf/preservation_services/rsync_replication_service.rb +183 -0
  36. data/lib/longleaf/services/application_config_deserializer.rb +4 -6
  37. data/lib/longleaf/services/application_config_manager.rb +4 -2
  38. data/lib/longleaf/services/application_config_validator.rb +1 -1
  39. data/lib/longleaf/services/configuration_validator.rb +1 -0
  40. data/lib/longleaf/services/metadata_deserializer.rb +47 -10
  41. data/lib/longleaf/services/metadata_serializer.rb +42 -6
  42. data/lib/longleaf/services/service_class_cache.rb +112 -0
  43. data/lib/longleaf/services/service_definition_manager.rb +5 -1
  44. data/lib/longleaf/services/service_definition_validator.rb +4 -4
  45. data/lib/longleaf/services/service_manager.rb +72 -9
  46. data/lib/longleaf/services/service_mapping_manager.rb +4 -3
  47. data/lib/longleaf/services/service_mapping_validator.rb +4 -4
  48. data/lib/longleaf/services/storage_location_manager.rb +26 -5
  49. data/lib/longleaf/services/storage_location_validator.rb +1 -1
  50. data/lib/longleaf/services/storage_path_validator.rb +2 -2
  51. data/lib/longleaf/specs/config_builder.rb +9 -5
  52. data/lib/longleaf/specs/custom_matchers.rb +9 -0
  53. data/lib/longleaf/specs/file_helpers.rb +60 -0
  54. data/lib/longleaf/version.rb +1 -1
  55. data/longleaf.gemspec +1 -0
  56. metadata +39 -7
  57. data/lib/longleaf/commands/abstract_command.rb +0 -37
data/README.md CHANGED
@@ -1,8 +1,11 @@
1
1
  # Longleaf
2
+ Longleaf is a command-line tool which allows users to configure a set of storage locations and define custom sets of preservation services to run on their contents. These services are executed in response to applicable preservation events issued by clients. Its primary goal is to provide tools to create a simple and customizable preservation environment. Longleaf:
2
3
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/longleaf`. To experiment with that code, run `bin/console` for an interactive prompt.
4
-
5
- TODO: Delete this and the text above, and describe your gem
4
+ * Offers a predictable command-line interface and integrates with standard command-line tools.
5
+ * Offers configurable and customizable criteria based preservation workflows.
6
+ * Provides a base set of tools and a framework for building extensions.
7
+ * Provides activity logging and notifications.
8
+ * Performs preservation services only when required.
6
9
 
7
10
  ## Installation
8
11
 
@@ -22,22 +25,41 @@ Or install it yourself as:
22
25
 
23
26
  ## Usage
24
27
 
28
+ #### Register a file
29
+ In order to register a new file with Longleaf, use the register command:
30
+ `longleaf register -c <config.yml> -f <path to file>`
31
+
32
+ In the case that a file's content is replaced, the file can be re-registered by providing the `--force` flag.
33
+
25
34
  #### Validate configuration files
26
35
  Application configuration files can be validated prior to usage with the following command:
27
- `longleaf validate_config <config.yml>`
36
+ `longleaf validate_config -c <config.yml>`
37
+
38
+ #### Output and logging
39
+
40
+ The primary output from Longleaf is directed to STDOUT, and contains both success and failure messages. If you would like to only return failure messages, you may provide the `--failure_only` flag.
41
+
42
+ Additional logging is sent to STDERR. To control the level of logging, you may provide the `--log-level` parameter, which expects the standard [Ruby Logger levels](https://ruby-doc.org/stdlib-2.4.0/libdoc/logger/rdoc/Logger.html). The default log level is 'WARN'.
43
+
44
+ Messages sent to STDOUT are duplicated to STDERR at 'INFO' level, so they are excluded by default. In order to store an ongoing log of activity and errors, you would perform the following:
45
+ `longleaf <command> --log-level 'INFO' 2> /logs/longleaf.log`
28
46
 
29
47
  ## Development
30
48
 
31
49
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
32
50
 
33
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
51
+ To run Longleaf with local changes without needing to do a local install, you may run `bundle exec exe/longleaf <command>`.
52
+
53
+ To install this gem onto your local machine, run `bundle exec rake install`. This will allow you to run `longleaf <command>` and places the gem into `pkg/`. Note: Only files committed to git will be included in the installed gem.
54
+
55
+ To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
34
56
 
35
57
  ## Contributing
36
58
 
37
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/longleaf.
59
+ Bug reports and pull requests are welcome on GitHub at https://gitlab.lib.unc.edu/cdr/longleaf.
38
60
 
39
61
 
40
62
  ## License
41
63
 
42
- The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
64
+ The gem is available as open source under the terms of the [Apache License 2.0](http://www.apache.org/licenses/LICENSE-2.0).
43
65
 
@@ -0,0 +1,107 @@
1
+ require 'longleaf/logging'
2
+
3
+ module Longleaf
4
+ # Selects and allows for iteration over files which match a provided set of selection criteria
5
+ class FileSelector
6
+ include Longleaf::Logging
7
+
8
+ # May only provide either file_paths or storage_locations
9
+ def initialize(file_paths: nil, storage_locations: nil, app_config:)
10
+ if nil_or_empty?(file_paths) && nil_or_empty?(storage_locations)
11
+ raise ArgumentError.new("Must provide either file paths or storage locations")
12
+ end
13
+ if !nil_or_empty?(file_paths) && !nil_or_empty?(storage_locations)
14
+ raise ArgumentError.new("Cannot provide both file paths and storage locations")
15
+ end
16
+ @app_config = app_config
17
+ # The top level paths targeted by this selector
18
+ @target_paths = file_paths
19
+ # The set of storage locations to select file paths from
20
+ @storage_locations = storage_locations
21
+ # Validate that the selected storage locations are known
22
+ unless @storage_locations.nil?
23
+ locations = @app_config.location_manager.locations
24
+ @storage_locations.each do |loc_name|
25
+ unless locations.key?(loc_name)
26
+ raise StorageLocationUnavailableError.new("Cannot select unknown storage location #{loc_name}.")
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ # @return [Array] a list of top level paths from which files will be selected
33
+ def target_paths
34
+ # If starting from locations, initialize by expanding locations out to their actual paths
35
+ if @target_paths.nil? && !@storage_locations.nil?
36
+ @target_paths = Array.new
37
+ @storage_locations.each do |loc_name|
38
+ @target_paths << @app_config.location_manager.locations[loc_name].path
39
+ end
40
+ end
41
+
42
+ @target_paths
43
+ end
44
+
45
+ # Get the next file path for this selector.
46
+ # @return [String] an absolute path to the next file targeted by this selector,
47
+ # or nil if no more files selected
48
+ def next_path
49
+ if @paths.nil?
50
+ # Start the paths listing out from the targetted set of paths for this selector
51
+ # In reverse order since using a LIFO structure
52
+ @paths = target_paths.reverse
53
+ end
54
+
55
+ # No more paths to return
56
+ return nil if @paths&.empty?
57
+
58
+ # Get the most recently added path for depth first traversal of selected paths
59
+ path = @paths.pop
60
+ until path.nil? do
61
+ @app_config.location_manager.verify_path_in_location(path)
62
+
63
+ if File.exist?(path)
64
+ if File.directory?(path)
65
+ logger.debug("Expanding directory #{path}")
66
+ # For a directory, add all children to file_paths
67
+ Dir.entries(path).sort.reverse.each do |child|
68
+ @paths << File.join(path, child) unless child == '.' or child == '..'
69
+ end
70
+ else
71
+ logger.debug("Returning file #{path}")
72
+ return path
73
+ end
74
+ else
75
+ raise InvalidStoragePathError.new("File #{path} does not exist.")
76
+ end
77
+
78
+ # Returned path was not a suitable file, try the next path
79
+ path = @paths.pop
80
+ end
81
+ end
82
+
83
+ # return [Array] a list of all storage locations being targeted by this selector
84
+ def storage_locations
85
+ # Determine what storage_locations are represented by the given file paths
86
+ if @storage_locations.nil? && !@target_paths.nil?
87
+ loc_set = Set.new
88
+ @target_paths.each do |path|
89
+ loc = @app_config.location_manager.get_location_by_path(path)
90
+ loc_set.add(loc.name) unless loc.nil?
91
+ end
92
+ @storage_locations = loc_set.to_a
93
+ end
94
+
95
+ if @storage_locations.nil?
96
+ @storage_locations = Array.new
97
+ end
98
+
99
+ @storage_locations
100
+ end
101
+
102
+ private
103
+ def nil_or_empty?(value)
104
+ value.nil? || value.empty?
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,99 @@
1
+ require 'longleaf/services/service_manager'
2
+ require 'longleaf/services/metadata_deserializer'
3
+ require 'longleaf/events/event_names'
4
+ require 'longleaf/errors'
5
+ require 'longleaf/logging'
6
+ require 'time'
7
+
8
+ module Longleaf
9
+ # Iterator for getting file candidates which have services which need to be run.
10
+ # Implementation uses metadata files directly from the filesystem for determinations
11
+ # about service status.
12
+ class ServiceCandidateFilesystemIterator
13
+ include Longleaf::Logging
14
+
15
+ def initialize(file_selector, event, app_config, force = false)
16
+ @file_selector = file_selector
17
+ @event = event
18
+ @app_config = app_config
19
+ @force = force
20
+ end
21
+
22
+ # Get the file record for the next candidate which needs services run which match the
23
+ # provided file_selector
24
+ # @return [FileRecord] file record of the next candidate with services needing to be run,
25
+ # or nil if there are no more candidates.
26
+ def next_candidate
27
+ loop do
28
+ begin
29
+ next_path = @file_selector.next_path
30
+ return nil if next_path.nil?
31
+
32
+ logger.debug("Evaluating candidate #{next_path}")
33
+ storage_loc = @app_config.location_manager.get_location_by_path(next_path)
34
+ file_rec = FileRecord.new(next_path, storage_loc)
35
+
36
+ # Skip over unregistered files
37
+ if !file_rec.metadata_present?
38
+ logger.debug("Ignoring unregistered file #{next_path}")
39
+ next
40
+ end
41
+
42
+ file_rec.metadata_record = MetadataDeserializer.deserialize(file_path: file_rec.metadata_path,
43
+ digest_algs: storage_loc.metadata_digests)
44
+
45
+ # Return the file record if it needs any services run
46
+ return file_rec if needs_run?(file_rec)
47
+ rescue InvalidStoragePathError => e
48
+ logger.warn("Skipping candidate file: #{e.message}")
49
+ end
50
+ end
51
+ end
52
+
53
+ # Iterate through the candidates in this object and execute the provided block with each
54
+ # candidate. A block is required.
55
+ def each
56
+ file_rec = next_candidate
57
+ until file_rec.nil?
58
+ yield file_rec
59
+
60
+ file_rec = next_candidate
61
+ end
62
+ end
63
+
64
+ private
65
+ # Returns true if the file record contains any services which need to be run
66
+ def needs_run?(file_rec)
67
+ md_rec = file_rec.metadata_record
68
+ storage_loc = file_rec.storage_location
69
+ service_manager = @app_config.service_manager
70
+
71
+ # File is not a valid candidate for services if it is deregistered, unless performing cleanup
72
+ if @event != EventNames::CLEANUP && md_rec.deregistered?
73
+ logger.debug("Skipping deregistered file: #{file_rec.path}")
74
+ return false
75
+ end
76
+
77
+ expected_services = service_manager.list_services(
78
+ location: storage_loc.name,
79
+ event: @event)
80
+
81
+ # When in force mode, candidate needs a run as long as there are any services configured for it.
82
+ if @force && expected_services.length > 0
83
+ logger.debug("Forcing run needed for file: #{file_rec.path}")
84
+ return true
85
+ end
86
+
87
+ expected_services.each do |service_name|
88
+ if service_manager.service_needed?(service_name, md_rec)
89
+ logger.debug("Service #{service_name} needed for file: #{file_rec.path}")
90
+ return true
91
+ end
92
+ end
93
+
94
+ logger.debug("Run not needed for file: #{file_rec.path}")
95
+ # No services needed to be run for this file
96
+ false
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,18 @@
1
+ require 'longleaf/candidates/service_candidate_filesystem_iterator'
2
+
3
+ module Longleaf
4
+ # Service which locates files that have services which need to be performed on them.
5
+ class ServiceCandidateLocator
6
+ def initialize(app_config)
7
+ @app_config = app_config
8
+ end
9
+
10
+ # Get a iterator of the candidates matching the given FileSelector which need services run.
11
+ # @param file_selector [FileSelector] selector identifying the files to pull candidates from.
12
+ # @return an iterator
13
+ def candidate_iterator(file_selector, event, force = false)
14
+ # Get filesystem based implementation
15
+ ServiceCandidateFilesystemIterator.new(file_selector, event, @app_config, force)
16
+ end
17
+ end
18
+ end
data/lib/longleaf/cli.rb CHANGED
@@ -2,16 +2,24 @@ require 'thor'
2
2
  require 'yaml'
3
3
  require 'longleaf/logging'
4
4
  require 'longleaf/errors'
5
+ require 'longleaf/commands/deregister_command'
5
6
  require 'longleaf/commands/validate_config_command'
7
+ require 'longleaf/commands/validate_metadata_command'
6
8
  require 'longleaf/commands/register_command'
9
+ require 'longleaf/commands/preserve_command'
10
+ require 'longleaf/candidates/file_selector'
7
11
 
8
12
  module Longleaf
13
+ # Main commandline interface setup for Longleaf using Thor.
9
14
  class CLI < Thor
10
15
  include Longleaf::Logging
11
16
 
12
17
  class_option(:config, :aliases => "-c",
13
18
  :default => ENV['LONGLEAF_CFG'],
19
+ :required => true,
14
20
  :desc => 'Absolute path to the application configuration used for this command. By default, the value of the environment variable LONGLEAF_CFG is used.')
21
+ class_option(:load_path, :aliases => "-I",
22
+ :desc => 'Specify comma seperated directories to add to the $LOAD_PATH, which can be used to specify additional paths from which to load preservation services.')
15
23
  # Logging/output options
16
24
  class_option(:failure_only,
17
25
  :type => :boolean,
@@ -41,7 +49,8 @@ module Longleaf
41
49
  setup_logger(options)
42
50
 
43
51
  config_path = options[:config]
44
- file_paths = options[:file]&.split(/\s*,\s*/)
52
+ app_config_manager = load_application_config(options[:config])
53
+ file_selector = create_file_selector(options[:file], nil, app_config_manager)
45
54
  if options[:checksums]
46
55
  checksums = options[:checksums]
47
56
  # validate checksum list format, must a comma delimited list of prefix:checksums
@@ -54,16 +63,75 @@ module Longleaf
54
63
  end
55
64
  end
56
65
 
57
- command = Longleaf::RegisterCommand.new(config_path)
58
- exit command.execute(file_paths: file_paths, force: options[:force], checksums: checksums)
66
+ command = RegisterCommand.new(app_config_manager)
67
+ exit command.execute(file_selector: file_selector, force: options[:force], checksums: checksums)
68
+ end
69
+
70
+ desc "deregister", "Deregister files with Longleaf"
71
+ method_option(:file, :aliases => "-f",
72
+ :required => true,
73
+ :desc => 'File or files to deregister. Paths must be absolute. If multiple files are provided, they must be comma separated.')
74
+ method_option(:force,
75
+ :type => :boolean,
76
+ :default => false,
77
+ :desc => 'Force the deregistration of already deregistered files.')
78
+ # Deregister event command
79
+ def deregister
80
+ setup_logger(options)
81
+
82
+ config_path = options[:config]
83
+ app_config_manager = load_application_config(options[:config])
84
+ file_selector = create_file_selector(options[:file], nil, app_config_manager)
85
+
86
+ command = DeregisterCommand.new(app_config_manager)
87
+ exit command.execute(file_selector: file_selector, force: options[:force])
88
+ end
89
+
90
+ desc "preserve", "Perform preservation services on files with Longleaf"
91
+ method_option(:file, :aliases => "-f",
92
+ :required => false,
93
+ :desc => 'File or files to preserve. Paths must be absolute. If multiple files are provided, they must be comma separated.')
94
+ method_option(:location, :aliases => "-s",
95
+ :required => false,
96
+ :desc => 'Name or comma separated names of storage locations to preserve.')
97
+ method_option(:force,
98
+ :type => :boolean,
99
+ :default => false,
100
+ :desc => 'Force the execution of preservation services, disregarding scheduling information.')
101
+ def preserve
102
+ setup_logger(options)
103
+
104
+ extend_load_path(options[:load_path])
105
+ app_config_manager = load_application_config(options[:config])
106
+ file_selector = create_file_selector(options[:file], options[:location], app_config_manager)
107
+
108
+ command = PreserveCommand.new(app_config_manager)
109
+ exit command.execute(file_selector: file_selector, force: options[:force])
59
110
  end
60
111
 
61
- desc "validate_config [CONFIG_PATH]", "Validate an application configuration file"
112
+ desc "validate_config", "Validate an application configuration file, provided using --config."
62
113
  # Application configuration validation command
63
- def validate_config(config_path)
114
+ def validate_config
115
+ setup_logger(options)
116
+
117
+ exit Longleaf::ValidateConfigCommand.new(options[:config]).execute
118
+ end
119
+
120
+ desc "validate_metadata", "Validate metadata files."
121
+ method_option(:file, :aliases => "-f",
122
+ :required => false,
123
+ :desc => 'File or files of which to validate the metadata. Paths must be absolute. If multiple files are provided, they must be comma separated.')
124
+ method_option(:location, :aliases => "-s",
125
+ :required => false,
126
+ :desc => 'Name or comma separated names of storage locations to validate.')
127
+ # File metadata validation command
128
+ def validate_metadata
64
129
  setup_logger(options)
65
130
 
66
- exit Longleaf::ValidateConfigCommand.new(config_path).execute
131
+ app_config_manager = load_application_config(options[:config])
132
+ file_selector = create_file_selector(options[:file], options[:location], app_config_manager)
133
+
134
+ exit Longleaf::ValidateMetadataCommand.new(app_config_manager).execute(file_selector: file_selector)
67
135
  end
68
136
 
69
137
  no_commands do
@@ -73,6 +141,34 @@ module Longleaf
73
141
  options[:log_format],
74
142
  options[:log_datetime])
75
143
  end
144
+
145
+ def load_application_config(config_path)
146
+ begin
147
+ app_manager = ApplicationConfigDeserializer.deserialize(config_path)
148
+ rescue ConfigurationError => err
149
+ logger.failure("Failed to load application configuration due to the following issue:\n#{err.message}")
150
+ exit 1
151
+ end
152
+ end
153
+
154
+ def create_file_selector(file_paths, storage_locations, app_config_manager)
155
+ file_paths = file_paths&.split(/\s*,\s*/)
156
+ storage_locations = storage_locations&.split(/\s*,\s*/)
157
+
158
+ begin
159
+ FileSelector.new(file_paths: file_paths, storage_locations: storage_locations, app_config: app_config_manager)
160
+ rescue ArgumentError => e
161
+ logger.failure(e.message)
162
+ exit 1
163
+ end
164
+ end
165
+
166
+ def extend_load_path(load_paths)
167
+ load_paths = load_paths&.split(/\s*,\s*/)
168
+ if !load_paths.nil?
169
+ load_paths.each { |path| $LOAD_PATH.unshift(path) }
170
+ end
171
+ end
76
172
  end
77
173
  end
78
174
  end
@@ -0,0 +1,50 @@
1
+ require 'longleaf/services/application_config_deserializer'
2
+ require 'longleaf/events/deregister_event'
3
+ require 'longleaf/models/file_record'
4
+ require 'longleaf/events/event_names'
5
+ require 'longleaf/events/event_status_tracking'
6
+ require 'longleaf/services/metadata_deserializer'
7
+
8
+ module Longleaf
9
+ # Command for deregistering files with longleaf
10
+ class DeregisterCommand
11
+ include Longleaf::EventStatusTracking
12
+
13
+ def initialize(app_manager)
14
+ @app_manager = app_manager
15
+ end
16
+
17
+ # Execute the deregister command on the given parameters
18
+ # @param file_selector [FileSelector] selector for files to deregister
19
+ # @param force [Boolean] force flag
20
+ # @return [Integer] status code
21
+ def execute(file_selector:, force: false)
22
+ begin
23
+ # Perform deregister events on each of the file paths provided
24
+ loop do
25
+ f_path = file_selector.next_path
26
+ break if f_path.nil?
27
+
28
+ storage_location = @app_manager.location_manager.get_location_by_path(f_path)
29
+
30
+ file_rec = FileRecord.new(f_path, storage_location)
31
+ unless file_rec.metadata_present?
32
+ raise DeregistrationError.new("Cannot deregister #{f_path}, file is not registered.")
33
+ end
34
+
35
+ file_rec.metadata_record = MetadataDeserializer.deserialize(file_path: file_rec.metadata_path,
36
+ digest_algs: storage_location.metadata_digests)
37
+
38
+ event = DeregisterEvent.new(file_rec: file_rec, force: force, app_manager: @app_manager)
39
+ track_status(event.perform)
40
+ end
41
+ rescue DeregistrationError, InvalidStoragePathError, StorageLocationUnavailableError => err
42
+ record_failure(EventNames::DEREGISTER, nil, err.message)
43
+ rescue => err
44
+ record_failure(EventNames::DEREGISTER, error: err)
45
+ end
46
+
47
+ return_status
48
+ end
49
+ end
50
+ end