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
@@ -0,0 +1,183 @@
1
+ require 'longleaf/events/event_names'
2
+ require 'longleaf/logging'
3
+ require 'longleaf/errors'
4
+ require 'longleaf/models/file_record'
5
+ require 'longleaf/models/service_fields'
6
+ require 'longleaf/events/register_event'
7
+ require 'open3'
8
+
9
+ module Longleaf
10
+ # Preservation service which performs replication of a file to one or more destinations using rsync.
11
+ #
12
+ # The service definition must contain one or more destinations, specified with the "to" property.
13
+ # These destinations must be either a known storage location name, a remote path, or absolute path.
14
+ #
15
+ # Optional service configuration properties:
16
+ # * replica_collision_policy = specifies the desired outcome if the service attempts to replicate
17
+ # a file which already exists at a destination. Default: "replace".
18
+ # * rsync_command = the command to invoke in order to execute rsync. Default: "rsync"
19
+ # * rsync_options = additional parameters that will be passed along to rsync. Cannot include options
20
+ # which change the target of the command or prevent its execution, such as "files-from", "dry-run",
21
+ # "help", etc. Command will always include "-R". Default "-a".
22
+ class RsyncReplicationService
23
+ include Longleaf::Logging
24
+
25
+ COLLISION_PROPERTY = "replica_collision_policy"
26
+ DEFAULT_COLLISION_POLICY = "replace"
27
+ VALID_COLLISION_POLICIES = ["replace"]
28
+
29
+ RSYNC_COMMAND_PROPERTY = "rsync_command"
30
+ DEFAULT_COMMAND = "rsync"
31
+
32
+ RSYNC_OPTIONS_PROPERTY = "rsync_options"
33
+ DEFAULT_OPTIONS = "-a"
34
+ DISALLOWED_OPTIONS = ["files-from", "n", "dry-run", "exclude", "exclude-from", "cvs-exclude",
35
+ "h", "help", "f", "F", "filter"]
36
+
37
+ attr_reader :command, :options, :collision_policy
38
+
39
+ # Initialize a RsyncReplicationService from the given service definition
40
+ #
41
+ # @param service_def [ServiceDefinition] the configuration for this service
42
+ # @param location_manager [StorageLocationManager] manager for configured storage locations
43
+ def initialize(service_def, app_manager)
44
+ @service_def = service_def
45
+ @app_manager = app_manager
46
+
47
+ @command = @service_def.properties[RSYNC_COMMAND_PROPERTY] || DEFAULT_COMMAND
48
+
49
+ # Validate rsync parameters
50
+ @options = @service_def.properties[RSYNC_OPTIONS_PROPERTY] || DEFAULT_OPTIONS
51
+ if contains_disallowed_option?(@options)
52
+ raise ArgumentError.new("Service #{service_def.name} specifies a disallowed rsync paramter," \
53
+ + " rsync_options may not include the following: #{DISALLOWED_OPTIONS.join(" ")}")
54
+ end
55
+
56
+ # Add -R (--relative) in to command options to ensure full path gets replicated
57
+ @options = @options + " -R"
58
+
59
+ # Set and validate the replica collision policy
60
+ @collision_policy = @service_def.properties[COLLISION_PROPERTY] || DEFAULT_COLLISION_POLICY
61
+ if !VALID_COLLISION_POLICIES.include?(@collision_policy)
62
+ raise ArgumentError.new("Service #{service_def.name} received invalid #{COLLISION_PROPERTY}" \
63
+ + " value #{collision_policy}")
64
+ end
65
+
66
+ # Store and validate destinations
67
+ replicate_to = @service_def.properties[ServiceFields::REPLICATE_TO]
68
+ if replicate_to.nil? || replicate_to.empty?
69
+ raise ArgumentError.new("Service #{service_def.name} must provide one or more replication destinations.")
70
+ end
71
+
72
+ loc_manager = app_manager.location_manager
73
+ # Build list of destinations, translating to storage locations when relevant
74
+ @destinations = Array.new
75
+ replicate_to.each do |dest|
76
+ # Assume that if destination contains a : or / it is a path rather than storage location
77
+ if dest =~ /[:\/]/
78
+ @destinations << dest
79
+ else
80
+ if loc_manager.locations.key?(dest)
81
+ @destinations << loc_manager.locations[dest]
82
+ else
83
+ raise ArgumentError.new("Service #{service_def.name} specifies unknown storage location '#{dest}'" \
84
+ + " as a replication destination")
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ # During a replication event, perform replication of the specified file to all configured destinations
91
+ # as necessary.
92
+ #
93
+ # @param file_rec [FileRecord] record representing the file to perform the service on.
94
+ # @param event [String] name of the event this service is being invoked by.
95
+ # @raise [PreservationServiceError] if the rsync replication fails
96
+ def perform(file_rec, event)
97
+ @destinations.each do |destination|
98
+ dest_is_storage_loc = destination.is_a?(Longleaf::StorageLocation)
99
+
100
+ if dest_is_storage_loc
101
+ dest_path = destination.path
102
+ else
103
+ dest_path = destination
104
+ end
105
+
106
+ # Determine the path to the file being replicated relative to its storage location
107
+ rel_path = file_rec.path.sub(/\A#{file_rec.storage_location.path}/, "")
108
+ # source path with . so that rsync will only create destination directories starting from that point
109
+ source_path = File.join(file_rec.storage_location.path, "./#{rel_path}")
110
+
111
+ # Check that the destination is available because attempting to write
112
+ verify_destination_available(destination, file_rec)
113
+
114
+ logger.debug("Invoking rsync with command: #{@command} \"#{source_path}\" \"#{dest_path}\" #{@options}")
115
+ stdout, stderr, status = Open3.capture3("#{@command} \"#{source_path}\" \"#{dest_path}\" #{@options}")
116
+ raise PreservationServiceError.new("Failed to replicate #{file_rec.path} to #{dest_path}: #{stderr}") \
117
+ unless status.success?
118
+
119
+ logger.info("Replicated #{file_rec.path} to destination #{dest_path}")
120
+
121
+ # For destinations which are storage locations, register the replica with longleaf
122
+ if dest_is_storage_loc
123
+ register_replica(destination, rel_path, file_rec)
124
+ end
125
+ end
126
+ end
127
+
128
+ # Determine if this service is applicable for the provided event, given the configured service definition
129
+ #
130
+ # @param event [String] name of the event
131
+ # @return [Boolean] returns true if this service is applicable for the provided event
132
+ def is_applicable?(event)
133
+ case event
134
+ when EventNames::PRESERVE
135
+ true
136
+ else
137
+ false
138
+ end
139
+ end
140
+
141
+ private
142
+ def contains_disallowed_option?(options)
143
+ DISALLOWED_OPTIONS.each do |disallowed|
144
+ if disallowed.length == 1
145
+ if options =~ /(\A| )-[a-zA-Z0-9]*#{disallowed}[a-zA-Z0-9]*( |=|\z)/
146
+ return true
147
+ end
148
+ else
149
+ if options =~ /(\A| )--#{disallowed}( |=|\z)/
150
+ return true
151
+ end
152
+ end
153
+ end
154
+
155
+ false
156
+ end
157
+
158
+ def verify_destination_available(destination, file_rec)
159
+ if destination.is_a?(Longleaf::StorageLocation)
160
+ begin
161
+ destination.available?
162
+ rescue StorageLocationUnavailableError => e
163
+ raise StorageLocationUnavailableError.new("Cannot replicate #{file_rec.path} to destination" \
164
+ + " storage location #{destination.name}, it is unavailable.") unless destination.available?
165
+ end
166
+ elsif destination.start_with?("/")
167
+ raise StorageLocationUnavailableError.new("Cannot replicate #{file_rec.path} to destination" \
168
+ + " #{destination}, path does not exist.") unless Dir.exist?(destination)
169
+ end
170
+ end
171
+
172
+ def register_replica(destination, rel_path, file_rec)
173
+ dest_file_path = File.join(destination.path, rel_path)
174
+ dest_file_rec = FileRecord.new(dest_file_path, destination)
175
+
176
+ register_event = RegisterEvent.new(file_rec: dest_file_rec,
177
+ app_manager: @app_manager,
178
+ force: true,
179
+ checksums: file_rec.metadata_record.checksums)
180
+ register_event.perform
181
+ end
182
+ end
183
+ end
@@ -1,14 +1,14 @@
1
1
  require 'longleaf/services/application_config_validator'
2
2
  require 'longleaf/services/application_config_manager'
3
3
 
4
- # Deserializer for application configuration files
5
4
  module Longleaf
5
+ # Deserializer for application configuration files
6
6
  class ApplicationConfigDeserializer
7
7
 
8
8
  # Deserializes a valid application configuration file as a ApplicationConfigManager option
9
9
  # @param config_path [String] file path to the application configuration file
10
10
  # @param format [String] encoding format of the config file
11
- # return [Longleaf::ApplicationConfigManager] manager for the loaded configuration
11
+ # return [ApplicationConfigManager] manager for the loaded configuration
12
12
  def self.deserialize(config_path, format: 'yaml')
13
13
  config = load(config_path, format: format)
14
14
 
@@ -35,11 +35,9 @@ module Longleaf
35
35
  YAML.load_file(config_path)
36
36
  rescue Errno::ENOENT => err
37
37
  raise Longleaf::ConfigurationError.new(
38
- "Cannot load application configuration, file #{config_path} does not exist.")
38
+ "Configuration file #{config_path} does not exist.")
39
39
  rescue => err
40
- raise Longleaf::ConfigurationError.new(
41
- %Q(Failed to load application configuration due to the following reason:
42
- #{err.message}))
40
+ raise Longleaf::ConfigurationError.new(err)
43
41
  end
44
42
  end
45
43
  end
@@ -6,8 +6,8 @@ require_relative 'service_mapping_validator'
6
6
  require_relative 'service_mapping_manager'
7
7
  require_relative 'service_manager'
8
8
 
9
- # Manager which loads and provides access to the configuration of the application
10
9
  module Longleaf
10
+ # Manager which loads and provides access to the configuration of the application
11
11
  class ApplicationConfigManager
12
12
  attr_reader :service_manager
13
13
  attr_reader :location_manager
@@ -18,7 +18,9 @@ module Longleaf
18
18
  definition_manager = Longleaf::ServiceDefinitionManager.new(config)
19
19
  mapping_manager = Longleaf::ServiceMappingManager.new(config)
20
20
  @service_manager = Longleaf::ServiceManager.new(
21
- definition_manager: definition_manager, mapping_manager: mapping_manager)
21
+ definition_manager: definition_manager,
22
+ mapping_manager: mapping_manager,
23
+ app_manager: self)
22
24
  end
23
25
  end
24
26
  end
@@ -2,8 +2,8 @@ require_relative 'storage_location_validator'
2
2
  require_relative 'service_definition_validator'
3
3
  require_relative 'service_mapping_validator'
4
4
 
5
- # Validator for Longleaf application configuration
6
5
  module Longleaf
6
+ # Validator for Longleaf application configuration
7
7
  class ApplicationConfigValidator
8
8
 
9
9
  # Validates the application configuration provided. Will raise ConfigurationError
@@ -1,4 +1,5 @@
1
1
  module Longleaf
2
+ # Abstract configuration validator class
2
3
  class ConfigurationValidator
3
4
  protected
4
5
  def self.assert(fail_message, assertion_passed)
@@ -1,26 +1,28 @@
1
1
  require 'yaml'
2
- require_relative '../models/metadata_record'
3
- require_relative '../models/md_fields'
4
- require_relative '../errors'
2
+ require 'longleaf/models/metadata_record'
3
+ require 'longleaf/models/md_fields'
4
+ require 'longleaf/errors'
5
+ require 'longleaf/logging'
5
6
 
6
- # Service which deserializes metadata files into MetadataRecord objects
7
7
  module Longleaf
8
+ # Service which deserializes metadata files into MetadataRecord objects
8
9
  class MetadataDeserializer
9
- MDF = Longleaf::MDFields
10
+ extend Longleaf::Logging
11
+ MDF ||= MDFields
10
12
 
11
13
  # Deserialize a file into a MetadataRecord object
12
14
  #
13
15
  # @param file_path [String] path of the file to read. Required.
14
16
  # @param format [String] format the file is stored in. Default is 'yaml'.
15
- def self.deserialize(file_path:, format: 'yaml')
17
+ def self.deserialize(file_path:, format: 'yaml', digest_algs: [])
16
18
  case format
17
19
  when 'yaml'
18
- md = from_yaml(file_path)
20
+ md = from_yaml(file_path, digest_algs)
19
21
  else
20
22
  raise ArgumentError.new('Invalid deserialization format #{format} specified')
21
23
  end
22
24
 
23
- if !md || !md.key?(MDF::DATA) || !md.key?(MDF::SERVICES)
25
+ if !md || !md.is_a?(Hash) || !md.key?(MDF::DATA) || !md.key?(MDF::SERVICES)
24
26
  raise Longleaf::MetadataError.new("Invalid metadata file, did not contain data or services fields: #{file_path}")
25
27
  end
26
28
 
@@ -61,8 +63,43 @@ module Longleaf
61
63
  last_modified: last_modified)
62
64
  end
63
65
 
64
- def self.from_yaml(file_path)
65
- YAML.load_file(file_path)
66
+ # Load configuration a yaml encoded configuration file
67
+ def self.from_yaml(file_path, digest_algs)
68
+ File.open(file_path, 'r:bom|utf-8') do |f|
69
+ contents = f.read
70
+
71
+ verify_digests(file_path, contents, digest_algs)
72
+
73
+ YAML.load(contents)
74
+ end
75
+ end
76
+
77
+ def self.verify_digests(file_path, contents, digest_algs)
78
+ return if digest_algs.nil? || digest_algs.empty?
79
+
80
+ digest_algs.each do |alg|
81
+ if file_path.respond_to?(:path)
82
+ path = file_path.path
83
+ else
84
+ path = file_path
85
+ end
86
+ digest_path = "#{path}.#{alg}"
87
+ unless File.exist?(digest_path)
88
+ logger.warn("Missing expected #{alg} digest for #{path}")
89
+ next
90
+ end
91
+
92
+ digest = DigestHelper::start_digest(alg)
93
+ result = digest.hexdigest(contents)
94
+ existing_digest = IO.read(digest_path)
95
+
96
+ if result == existing_digest
97
+ logger.info("Metadata fixity check using algorithm '#{alg}' succeeded for file #{path}")
98
+ else
99
+ raise ChecksumMismatchError.new("Metadata digest of type #{alg} did not match the contents of #{path}:" \
100
+ + " expected #{existing_digest}, calculated #{result}")
101
+ end
102
+ end
66
103
  end
67
104
  end
68
105
  end
@@ -1,20 +1,27 @@
1
1
  require 'yaml'
2
- require_relative '../models/metadata_record'
3
- require_relative '../models/md_fields'
2
+ require 'longleaf/models/metadata_record'
3
+ require 'longleaf/models/md_fields'
4
+ require 'longleaf/helpers/digest_helper'
5
+ require 'longleaf/errors'
6
+ require 'longleaf/logging'
7
+ require 'pathname'
4
8
 
5
- # Service which serializes MetadataRecord objects
6
9
  module Longleaf
10
+ # Service which serializes MetadataRecord objects
7
11
  class MetadataSerializer
8
- MDF = Longleaf::MDFields
12
+ extend Longleaf::Logging
13
+ MDF ||= MDFields
9
14
 
10
15
  # Serialize the contents of the provided metadata record to the specified path
11
16
  #
12
17
  # @param metadata [MetadataRecord] metadata record to serialize. Required.
13
18
  # @param file_path [String] path to write the file to. Required.
14
19
  # @param format [String] format to serialize the metadata in. Default is 'yaml'.
15
- def self.write(metadata:, file_path:, format: 'yaml')
20
+ # @param digest_algs [Array] if provided, sidecar digest files for the metadata file
21
+ # will be generated for each algorithm.
22
+ def self.write(metadata:, file_path:, format: 'yaml', digest_algs: [])
16
23
  raise ArgumentError.new('metadata parameter must be a MetadataRecord') \
17
- unless metadata.class == Longleaf::MetadataRecord
24
+ unless metadata.class == MetadataRecord
18
25
 
19
26
  case format
20
27
  when 'yaml'
@@ -23,7 +30,12 @@ module Longleaf
23
30
  raise ArgumentError.new('Invalid serialization format #{format} specified')
24
31
  end
25
32
 
33
+ # Fill in parent directories if they do not exist
34
+ parent_dir = Pathname(file_path).parent
35
+ parent_dir.mkpath unless parent_dir.exist?
36
+
26
37
  File.write(file_path, content)
38
+ write_digests(file_path, content, digest_algs)
27
39
  end
28
40
 
29
41
  # @param metadata [MetadataRecord] metadata record to transform
@@ -33,6 +45,8 @@ module Longleaf
33
45
  props.to_yaml
34
46
  end
35
47
 
48
+ # Create a hash representation of the given MetadataRecord file
49
+ # @param metadata [MetadataRecord] metadata record to transform into a hash
36
50
  def self.to_hash(metadata)
37
51
  props = Hash.new
38
52
 
@@ -59,6 +73,9 @@ module Longleaf
59
73
  props
60
74
  end
61
75
 
76
+ # @param format [String] encoding format used for metadata file
77
+ # @return [String] the suffix used to indicate that a file is a metadata file in the provided encoding
78
+ # @raise [ArgumentError] raised if the provided format is not a supported metadata encoding format
62
79
  def self.metadata_suffix(format: 'yaml')
63
80
  case format
64
81
  when 'yaml'
@@ -67,5 +84,24 @@ module Longleaf
67
84
  raise ArgumentError.new('Invalid serialization format #{format} specified')
68
85
  end
69
86
  end
87
+
88
+ private
89
+ def self.write_digests(file_path, content, digests)
90
+ return if digests.nil? || digests.empty?
91
+
92
+ digests.each do |alg|
93
+ digest_class = DigestHelper::start_digest(alg)
94
+ result = digest_class.hexdigest(content)
95
+ if file_path.respond_to?(:path)
96
+ digest_path = "#{file_path.path}.#{alg}"
97
+ else
98
+ digest_path = "#{file_path}.#{alg}"
99
+ end
100
+
101
+ File.write(digest_path, result)
102
+
103
+ self.logger.debug("Generated #{alg} digest for metadata file #{file_path}: #{result}")
104
+ end
105
+ end
70
106
  end
71
107
  end
@@ -0,0 +1,112 @@
1
+ require 'pathname'
2
+
3
+ module Longleaf
4
+ # Cache for loading and retrieving preservation service classes
5
+ class ServiceClassCache
6
+ STD_PRESERVATION_SERVICE_PATH = 'longleaf/preservation_services/'
7
+
8
+ def initialize(app_manager)
9
+ @app_manager = app_manager
10
+ # Cache storing per service definition instances of service classes
11
+ @service_instance_cache = Hash.new
12
+ # Cache storing per script path class of service
13
+ @class_cache = Hash.new
14
+ end
15
+
16
+ # Returns an instance of the preversation service defined for the provided service definition,
17
+ # based on the work_script and work_class properties provided.
18
+ #
19
+ # @param service_def [ServiceDefinition] definition of service to instantiate
20
+ # @return [PreservationService] Instance of the preservation service class for the definition.
21
+ def service_instance(service_def)
22
+ service_name = service_def.name
23
+ # Return the cached instance of the service
24
+ if @service_instance_cache.key?(service_name)
25
+ return @service_instance_cache[service_name]
26
+ end
27
+
28
+ clazz = service_class(service_def)
29
+ # Cache and return the class instance
30
+ @service_instance_cache[service_name] = clazz.new(service_def, @app_manager)
31
+ end
32
+
33
+ # Load and return the PreservationService class assigned to the provided service definition,
34
+ # based on the work_script and work_class properties provided.
35
+ #
36
+ # @param service_def [ServiceDefinition] definition of service to retrieve class for
37
+ # @return [Class] class of work_script
38
+ def service_class(service_def)
39
+ service_name = service_def.name
40
+ work_script = service_def.work_script
41
+
42
+ if work_script.include?('/')
43
+ expanded_path = Pathname.new(work_script).expand_path.to_s
44
+ if !from_permitted_path?(expanded_path)
45
+ raise ConfigurationError.new("Unable to load work_script for service #{service_name}, #{work_script} is not in a known library path.")
46
+ end
47
+
48
+ last_slash_index = work_script.rindex('/')
49
+ script_path = work_script[0..last_slash_index]
50
+ script_name = work_script[(last_slash_index + 1)..-1]
51
+ else
52
+ script_path = STD_PRESERVATION_SERVICE_PATH
53
+ script_name = work_script
54
+ end
55
+
56
+ # Strip off the extension
57
+ script_name.sub!('.rb', '')
58
+
59
+ require_path = File.join(script_path, script_name)
60
+ # Return the cached Class if this path has been encountered before
61
+ if @class_cache.key?(require_path)
62
+ return @class_cache[require_path]
63
+ end
64
+
65
+ # Load the script
66
+ begin
67
+ require require_path
68
+ rescue LoadError => e
69
+ raise ConfigurationError.new("Failed to load work_script '#{script_name}' for service #{service_name}")
70
+ end
71
+
72
+ # Generate the class name, either configured or from file naming convention if possible
73
+ if service_def.work_class
74
+ class_name = service_def.work_class
75
+ else
76
+ class_name = script_name.split('_').map(&:capitalize).join
77
+ # Assume the longleaf module for classes in the standard path
78
+ class_name = 'Longleaf::' + class_name if script_path == STD_PRESERVATION_SERVICE_PATH
79
+ end
80
+
81
+ begin
82
+ class_constant = constantize(class_name)
83
+ # cache the class for this work_script and return it
84
+ @class_cache[require_path] = class_constant
85
+ rescue NameError
86
+ raise ConfigurationError.new("Failed to load work_script '#{script_name}' for service #{service_name}, class name #{class_name} was not found.")
87
+ end
88
+ end
89
+
90
+ private
91
+ # Borrowed from sidekiq implementation
92
+ def constantize(str)
93
+ names = str.split('::')
94
+ names.shift if names.empty? || names.first.empty?
95
+
96
+ names.inject(Object) do |constant, name|
97
+ # the false flag limits search for name to under the constant namespace
98
+ # which mimics Rails' behaviour
99
+ constant.const_defined?(name, false) ? constant.const_get(name, false) : constant.const_missing(name)
100
+ end
101
+ end
102
+
103
+ def from_permitted_path?(script_path)
104
+ $LOAD_PATH.each do |lib_path|
105
+ if script_path.start_with?(lib_path)
106
+ return true
107
+ end
108
+ end
109
+ false
110
+ end
111
+ end
112
+ end