s3_migration 0.1.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.
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Migration
4
+ # Handles the disk copy of one manifest entry after migration: move it into <storage>/.quarantine
5
+ # (a rename on the same volume), restore it, and later purge old quarantined files. Every action
6
+ # is driven by manifest entries, so it is limited to the entries the manifest scope selects.
7
+ # All actions are dry runs unless apply: true.
8
+ class Quarantine
9
+ DIRECTORY = '.quarantine'
10
+
11
+ def self.base_path
12
+ SourceStorage.base_path
13
+ end
14
+
15
+ # Deletes the quarantined copies of the files in the manifest, and nothing else.
16
+ # @return [Array<Integer>] [files purged (or that would be), files too recent]
17
+ def self.purge(apply:, min_age:, base: base_path, manifest: Manifest.new)
18
+ raise ArgumentError, "min_age must be positive, got #{min_age.inspect}" unless min_age.to_f.positive?
19
+
20
+ old, recent = quarantined_files(manifest, base).partition { |file| Time.current - File.mtime(file) >= min_age }
21
+ old.each { |file| delete_with_empty_parents(file, base) } if apply
22
+ [old.size, recent.size]
23
+ end
24
+
25
+ def self.quarantined_files(manifest, base)
26
+ manifest.each_entry.filter_map { |entry| quarantine_path(entry, base) }.select { |file| File.file?(file) }
27
+ end
28
+
29
+ def self.disk_path(entry, base)
30
+ candidate = SourceStorage.path_for(entry[:old_id])
31
+ candidate if candidate.start_with?("#{base}/")
32
+ end
33
+
34
+ def self.quarantine_path(entry, base)
35
+ path = disk_path(entry, base)
36
+ File.join(base, DIRECTORY, path.delete_prefix("#{base}/")) if path
37
+ end
38
+
39
+ def self.delete_with_empty_parents(file, base)
40
+ root = File.join(base, DIRECTORY)
41
+ File.delete(file)
42
+ dir = File.dirname(file)
43
+ while dir.start_with?("#{root}/") && Dir.empty?(dir)
44
+ Dir.rmdir(dir)
45
+ dir = File.dirname(dir)
46
+ end
47
+ rescue Errno::ENOENT, Errno::ENOTEMPTY
48
+ nil # another purge got there first
49
+ end
50
+
51
+ def initialize(entry, apply: false, verify_sha: true, base: nil, s3_storage: nil)
52
+ @entry = entry
53
+ @apply = apply
54
+ @verify_sha = verify_sha
55
+ @base = base || self.class.base_path
56
+ @s3_storage = s3_storage || Valkyrie::StorageAdapter.find(:repository_s3)
57
+ end
58
+
59
+ # @return [Symbol] :quarantined, :already_quarantined, :disk_copy_missing, :outside_base,
60
+ # :database_changed or :s3_mismatch
61
+ def quarantine
62
+ return :outside_base unless path
63
+ return (File.exist?(destination) ? :already_quarantined : :disk_copy_missing) unless File.exist?(path)
64
+ return :database_changed unless current_identifier == entry[:new_id]
65
+ return :s3_mismatch unless s3_matches_manifest?
66
+
67
+ quarantine_file if apply
68
+ :quarantined
69
+ end
70
+
71
+ # Puts the disk copy and the original identifier back, then removes the S3 upload and manifest entry
72
+ # nothing refers to any more. The disk copy is checked against the manifest first.
73
+ # @return [Symbol] :rolled_back, :skipped, :disk_copy_gone, :disk_copy_mismatch or :outside_base
74
+ def rollback
75
+ return :outside_base unless path
76
+ return :skipped unless current_identifier == entry[:new_id]
77
+ return :disk_copy_gone unless File.exist?(path) || File.exist?(destination)
78
+ return :disk_copy_mismatch unless disk_copy_matches_manifest?
79
+
80
+ restore if apply
81
+ :rolled_back
82
+ end
83
+
84
+ private
85
+
86
+ attr_reader :entry, :apply, :verify_sha, :base, :s3_storage
87
+
88
+ def manifest
89
+ @manifest ||= Manifest.new(storage: s3_storage)
90
+ end
91
+
92
+ def path
93
+ self.class.disk_path(entry, base)
94
+ end
95
+
96
+ def destination
97
+ self.class.quarantine_path(entry, base)
98
+ end
99
+
100
+ def current
101
+ @current ||= Hyrax.query_service.find_by(id: entry[:file_metadata_id])
102
+ end
103
+
104
+ def current_identifier
105
+ current.file_identifier.to_s
106
+ end
107
+
108
+ def s3_matches_manifest?
109
+ s3_file = s3_storage.find_by(id: entry[:new_id])
110
+ return false unless s3_file.size.to_i == entry[:size]
111
+
112
+ !verify_sha || s3_file.checksum(digests: [Digest::SHA256.new]).first == entry[:sha256]
113
+ end
114
+
115
+ def disk_copy_matches_manifest?
116
+ file = File.exist?(path) ? path : destination
117
+ File.size(file) == entry[:size] && (!verify_sha || Digest::SHA256.file(file).hexdigest == entry[:sha256])
118
+ end
119
+
120
+ def quarantine_file
121
+ FileUtils.touch(path) # a rename keeps the old mtime, which purge reads as the time quarantined
122
+ move(path, destination)
123
+ end
124
+
125
+ def move(from, to)
126
+ FileUtils.mkdir_p(File.dirname(to))
127
+ FileUtils.mv(from, to)
128
+ end
129
+
130
+ def restore
131
+ move(destination, path) unless File.exist?(path)
132
+ current.file_identifier = Valkyrie::ID.new(entry[:old_id])
133
+ Hyrax.persister.save(resource: current)
134
+ discard_upload
135
+ end
136
+
137
+ # The rollback has already succeeded, so a failure here only leaves an unused upload behind.
138
+ def discard_upload
139
+ upload_cleanup.call(entry[:new_id])
140
+ rescue StandardError => e
141
+ log_discard_failure(e)
142
+ end
143
+
144
+ def upload_cleanup
145
+ UploadCleanup.new(file_set_id: entry[:file_set_id], file_metadata_id: entry[:file_metadata_id],
146
+ s3_storage:, manifest:)
147
+ end
148
+
149
+ def log_discard_failure(error)
150
+ Hyrax.logger.warn("S3Migration: rolled back #{entry[:file_set_id]} but could not remove " \
151
+ "#{entry[:new_id]}: #{error.class}: #{error.message}")
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Migration
4
+ # Registers the s3_migration:* rake tasks with the host application.
5
+ class Railtie < Rails::Railtie
6
+ rake_tasks { load File.expand_path('../tasks/s3_migration.rake', __dir__) }
7
+ end
8
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Migration
4
+ # The Valkyrie storage adapter whose files are moved to S3. Only a plain Valkyrie::Storage::Disk is
5
+ # supported: versioned disk keeps versions and deletion markers that a copy to S3 would lose.
6
+ module SourceStorage
7
+ class Unsupported < StandardError; end
8
+
9
+ class << self
10
+ attr_writer :adapter_name
11
+
12
+ # @return [Symbol] the name the source adapter is registered under (default :disk)
13
+ def adapter_name
14
+ @adapter_name ||= :disk
15
+ end
16
+
17
+ # @return [Valkyrie::Storage::Disk]
18
+ # @raise [Unsupported] when no such adapter is registered, or it is not a plain disk adapter
19
+ def adapter
20
+ found = Valkyrie::StorageAdapter.find(adapter_name)
21
+ return found if found.is_a?(Valkyrie::Storage::Disk)
22
+
23
+ raise Unsupported, "The :#{adapter_name} storage adapter is a #{found.class}. Only Valkyrie::Storage::Disk " \
24
+ '(disk:// ids) can be migrated; see docs/migrating-files-to-s3.md.'
25
+ rescue Valkyrie::StorageAdapter::AdapterNotFoundError
26
+ raise Unsupported, "No Valkyrie storage adapter is registered as :#{adapter_name} (registered: " \
27
+ "#{Valkyrie::StorageAdapter.storage_adapters.keys.join(', ')}). Set " \
28
+ 'S3Migration::SourceStorage.adapter_name to the plain disk adapter that holds the files.'
29
+ end
30
+
31
+ # @param identifier [#to_s] a file identifier
32
+ def handles?(identifier)
33
+ adapter.handles?(id: Valkyrie::ID.new(identifier.to_s))
34
+ end
35
+
36
+ def path_for(identifier)
37
+ adapter.file_path(identifier)
38
+ end
39
+
40
+ def base_path
41
+ adapter.base_path.to_s
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Migration
4
+ # Tallies the files by use (original, thumbnail, ...) and storage scheme.
5
+ class StorageReport
6
+ # @param progress [S3Migration::Progress, nil] ticked once per file set
7
+ # @return [Hash{Array<String>=>Array<Integer>}] { [use, scheme] => [file count, bytes] }
8
+ def self.call(progress: nil)
9
+ totals = Hash.new { |hash, key| hash[key] = [0, 0] }
10
+ S3Migration.each_file_set do |file_set|
11
+ progress&.tick
12
+ add_files(totals, files_for(file_set))
13
+ end
14
+ totals
15
+ end
16
+
17
+ def self.add_files(totals, files)
18
+ totals[['(file record missing)', '-']][0] += 1 unless files
19
+ files&.each { |file| add_file(totals, file) }
20
+ end
21
+
22
+ def self.add_file(totals, file)
23
+ count_and_bytes = totals[[use_for(file), file.file_identifier.to_s[/\A[a-z]+(?=:)/].to_s]]
24
+ count_and_bytes[0] += 1
25
+ count_and_bytes[1] += file.recorded_size.first.to_i
26
+ end
27
+
28
+ def self.files_for(file_set)
29
+ Hyrax.custom_queries.find_files(file_set:)
30
+ rescue Valkyrie::Persistence::ObjectNotFoundError
31
+ nil
32
+ end
33
+
34
+ def self.use_for(file)
35
+ Array(file.pcdm_use).map { |uri| uri.to_s.split('#').last }.join(',')
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Migration
4
+ # The checksum Hyrax recorded for a file at characterization (FileMetadata#checksum). FITS reports
5
+ # a bare MD5 hex string; an explicit urn:<algorithm>: prefix is also understood. Anything else
6
+ # (unknown prefix, wrong length) is treated as no checksum rather than as a mismatch.
7
+ class StoredChecksum
8
+ ALGORITHMS = { 'md5' => [Digest::MD5, 32], 'sha1' => [Digest::SHA1, 40], 'sha256' => [Digest::SHA256, 64] }.freeze
9
+
10
+ attr_reader :algorithm, :digest
11
+
12
+ # @return [StoredChecksum, nil] nil when there is no usable checksum
13
+ def self.for(original)
14
+ match = /\A(?:urn:(?<algorithm>md5|sha1|sha256):)?(?<digest>\h+)\z/i.match(original.checksum.first.to_s.strip)
15
+ return unless match
16
+
17
+ algorithm = match[:algorithm]&.downcase || 'md5'
18
+ digest = match[:digest].downcase
19
+ new(algorithm, digest) if digest.length == ALGORITHMS.fetch(algorithm).last
20
+ end
21
+
22
+ def initialize(algorithm, digest)
23
+ @algorithm = algorithm
24
+ @digest = digest
25
+ end
26
+
27
+ def digest_class
28
+ ALGORITHMS.fetch(algorithm).first
29
+ end
30
+
31
+ def to_s
32
+ "#{algorithm}:#{digest}"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Migration
4
+ # The behaviour behind the s3_migration:* rake tasks. The environment and output are injected so it can be tested.
5
+ class Tasks
6
+ # Raised where a task should stop with a message instead of running.
7
+ class Abort < StandardError; end
8
+
9
+ ACTED_ON = %i[quarantined rolled_back].freeze
10
+
11
+ # Runs one task for its rake wrapper, turning an Abort into a rake abort.
12
+ def self.run(name)
13
+ new.public_send(name)
14
+ rescue Abort => e
15
+ abort e.message
16
+ end
17
+
18
+ def initialize(env: ENV, out: $stdout)
19
+ @env = env
20
+ @out = out
21
+ end
22
+
23
+ def migrate
24
+ require_storage!
25
+ dry_run = flag('DRY_RUN')
26
+ return enqueue unless dry_run || flag('INLINE')
27
+
28
+ out.puts "[DRY RUN] No changes will be made.\n\n" if dry_run
29
+ migrate_inline(dry_run:)
30
+ end
31
+
32
+ def verify
33
+ counts = Hash.new(0)
34
+ each_original(counts) do |file_set, original|
35
+ guarded(counts, file_set, :problem) { audit_counted?(counts, file_set, original) }
36
+ end
37
+ out.puts "\nOK: #{counts[:ok]} Problems: #{counts[:problem]} " \
38
+ "Not yet in S3: #{counts[:not_in_s3]} No original: #{counts[:no_original]}"
39
+ end
40
+
41
+ def report
42
+ out.puts 'use | scheme | files | GB'
43
+ StorageReport.call(progress: Progress.new).sort.each do |(use, scheme), (count, bytes)|
44
+ out.puts "#{use} | #{scheme} | #{count} | #{(bytes / 1e9).round(2)}"
45
+ end
46
+ end
47
+
48
+ def quarantine
49
+ require_storage!
50
+ out.puts "[DRY RUN] Nothing will be moved. Set APPLY=true to move.\n\n" unless flag('APPLY')
51
+ each_entry('Quarantine', :quarantine)
52
+ end
53
+
54
+ def rollback
55
+ require_storage!
56
+ out.puts "[DRY RUN] Nothing will change. Set APPLY=true to roll back.\n\n" unless flag('APPLY')
57
+ each_entry('Rolling back', :rollback)
58
+ end
59
+
60
+ def purge
61
+ require_storage!
62
+ apply = flag('APPLY')
63
+ out.puts "[DRY RUN] Nothing will be deleted. Set APPLY=true to delete.\n\n" unless apply
64
+ purged, kept = Quarantine.purge(apply:, min_age: min_age_days.days)
65
+ out.puts "#{apply ? 'Deleted' : 'Would delete'}: #{purged} Too recent: #{kept}"
66
+ end
67
+
68
+ private
69
+
70
+ attr_reader :env, :out
71
+
72
+ def flag(name, default: 'false')
73
+ ActiveModel::Type::Boolean.new.cast(env.fetch(name, default))
74
+ end
75
+
76
+ def limit
77
+ env['LIMIT']&.to_i
78
+ end
79
+
80
+ def min_age_days
81
+ days = Float(env.fetch('MIN_AGE_DAYS', '14'), exception: false)
82
+ unless days&.positive? && days.finite?
83
+ raise Abort,
84
+ "MIN_AGE_DAYS must be a positive number, got #{env['MIN_AGE_DAYS'].inspect}."
85
+ end
86
+
87
+ days
88
+ end
89
+
90
+ def require_storage!
91
+ unless Valkyrie::StorageAdapter.storage_adapters.key?(:repository_s3)
92
+ raise Abort,
93
+ 'Set up the :repository_s3 Valkyrie storage adapter first (see docs/migrating-files-to-s3.md).'
94
+ end
95
+
96
+ SourceStorage.adapter
97
+ rescue SourceStorage::Unsupported => e
98
+ raise Abort, e.message
99
+ end
100
+
101
+ def enqueue
102
+ MigrateFilesJob.perform_later(limit:)
103
+ out.puts 'Enqueued S3Migration::MigrateFilesJob'
104
+ end
105
+
106
+ def tally(counts)
107
+ counts.map { |status, count| "#{status}: #{count}" }.join(' ')
108
+ end
109
+
110
+ def each_original(counts, &)
111
+ progress = Progress.new(detail: -> { tally(counts) })
112
+ on_error = lambda do |file_set, error|
113
+ counts[:no_original] += 1
114
+ out.puts " NO_ORIGINAL #{file_set.id} - #{missing_original_detail(file_set, error)}"
115
+ end
116
+ S3Migration.each_original(limit:, progress:, on_error:, &)
117
+ end
118
+
119
+ def missing_original_detail(file_set, error)
120
+ if file_set.try(:file_ids).blank?
121
+ 'file set has no files'
122
+ elsif error.is_a?(Valkyrie::Persistence::ObjectNotFoundError)
123
+ 'original file record not found'
124
+ else
125
+ "#{error.class}: #{error.message}"
126
+ end
127
+ end
128
+
129
+ def migrate_inline(dry_run:)
130
+ counts = Hash.new(0)
131
+ each_original(counts) do |file_set, original|
132
+ guarded(counts, file_set, :error) { migrate_counted?(counts, file_set, original, dry_run:) }
133
+ end
134
+ out.puts "\nExamined #{counts.values.sum} file sets. #{tally(counts)}".strip
135
+ out.puts 'No file sets were found; check the model names and query services.' if counts.empty?
136
+ end
137
+
138
+ # Runs the block for one file set; a StandardError is counted under key and printed, and the run goes on.
139
+ def guarded(counts, file_set, key)
140
+ yield
141
+ rescue StandardError => e
142
+ counts[key] += 1
143
+ out.puts " #{key.to_s.upcase} #{file_set.id} - #{e.class}: #{e.message}"
144
+ true
145
+ end
146
+
147
+ def audit_counted?(counts, file_set, original)
148
+ result = Auditor.new(original:).call
149
+ counts[result.status] += 1
150
+ return false if result.status == :not_in_s3
151
+
152
+ out.puts " PROBLEM #{file_set.id} - #{result.problems.join('; ')}" if result.status == :problem
153
+ true
154
+ end
155
+
156
+ def migrate_counted?(counts, file_set, original, dry_run:)
157
+ result = FileMigrator.new(file_set:, original:, dry_run:).call
158
+ counts[result.status] += 1
159
+ return false if result.status == :already_migrated
160
+
161
+ out.puts " #{result.status.to_s.upcase} #{file_set.id} #{result.message}".rstrip
162
+ true
163
+ end
164
+
165
+ def each_entry(label, action)
166
+ counts = Hash.new(0)
167
+ progress = Progress.new(detail: -> { tally(counts) })
168
+ Manifest.new.each_entry do |entry|
169
+ progress.tick
170
+ process_entry(entry, counts, label, action)
171
+ end
172
+ out.puts "\n#{tally(counts)}".strip
173
+ out.puts 'No manifest entries found.' if counts.empty?
174
+ end
175
+
176
+ def process_entry(entry, counts, label, action)
177
+ status = Quarantine.new(entry, apply: flag('APPLY'),
178
+ verify_sha: flag('VERIFY_SHA', default: 'true')).public_send(action)
179
+ counts[status] += 1
180
+ out.puts " #{label} #{entry[:file_set_id]}" if ACTED_ON.include?(status)
181
+ rescue StandardError => e
182
+ counts[:error] += 1
183
+ out.puts " ERROR #{entry[:file_set_id]} - #{e.class}: #{e.message}"
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Migration
4
+ # Removes an upload the database does not point at, along with the manifest entry that names it.
5
+ class UploadCleanup
6
+ def initialize(file_set_id:, file_metadata_id:, s3_storage:, manifest:)
7
+ @file_set_id = file_set_id.to_s
8
+ @file_metadata_id = file_metadata_id
9
+ @s3_storage = s3_storage
10
+ @manifest = manifest
11
+ end
12
+
13
+ # The save can raise after committing (a timeout, say), so the database decides, not the exception.
14
+ # @param upload_id [Valkyrie::ID, String] the S3 identifier to discard
15
+ def call(upload_id)
16
+ upload_id = Valkyrie::ID.new(upload_id.to_s)
17
+ return if referenced?(upload_id)
18
+
19
+ s3_storage.delete(id: upload_id)
20
+ forget_entry(upload_id)
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :file_set_id, :file_metadata_id, :s3_storage, :manifest
26
+
27
+ # When the database cannot be read, keep the upload: the manifest entry still points at it.
28
+ def referenced?(upload_id)
29
+ Hyrax.query_service.find_by(id: file_metadata_id).file_identifier == upload_id
30
+ rescue StandardError
31
+ true
32
+ end
33
+
34
+ # Only an entry naming this upload goes; another process's entry for the file set stays. Failing here
35
+ # must not hide the error that sent the migration to cleanup.
36
+ def forget_entry(upload_id)
37
+ manifest.delete(file_set_id) if manifest.read(file_set_id)&.dig(:new_id) == upload_id.to_s
38
+ rescue StandardError => e
39
+ Hyrax.logger.warn("S3Migration: could not remove the manifest entry for #{file_set_id}: #{e.class}: #{e.message}")
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Migration
4
+ # Remembers which objects exist under a file set's prefix before an upload. valkyrie-shrine checks the copy
5
+ # inside upload, so an upload that fails verification has already written its object, under a key the caller
6
+ # never receives; this finds and removes it. Only safe while FileLock keeps other jobs from uploading for the
7
+ # same file set.
8
+ class UploadWatch
9
+ def initialize(s3_storage:, file_set_id:)
10
+ shrine = s3_storage.shrine
11
+ @bucket = shrine.bucket
12
+ @prefix = "#{[shrine.prefix, file_set_id].compact.join('/')}/"
13
+ @before = keys
14
+ end
15
+
16
+ # Removes the objects created since the snapshot. It never raises: it runs while another error is propagating.
17
+ def discard_new
18
+ @bucket.objects(prefix: @prefix).each { |object| object.delete unless @before.include?(object.key) }
19
+ rescue StandardError => e
20
+ Hyrax.logger.warn("S3Migration: could not remove objects left under #{@prefix}: #{e.class}: #{e.message}")
21
+ end
22
+
23
+ private
24
+
25
+ def keys
26
+ @bucket.objects(prefix: @prefix).map(&:key)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Migration
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'valkyrie/shrine' # Shrine and the S3 adapter's errors are used here; do not rely on the app requiring them
4
+ require 'zeitwerk'
5
+
6
+ loader = Zeitwerk::Loader.for_gem
7
+ loader.ignore("#{__dir__}/tasks")
8
+ loader.setup
9
+
10
+ # Moves original files from the disk storage adapter to the S3 repository adapter
11
+ # (see docs/migrating-files-to-s3.md).
12
+ module S3Migration
13
+ class << self
14
+ # Hosts that partition one repository into several (e.g. multitenant apps) can set a callable
15
+ # returning a label for the current partition; manifest entries are then kept per label, so
16
+ # the quarantine and rollback tasks only see the current partition's files.
17
+ attr_writer :scope_provider
18
+
19
+ def scope_provider
20
+ @scope_provider ||= -> {}
21
+ end
22
+
23
+ def scope
24
+ scope_provider.call.presence
25
+ end
26
+
27
+ # Yields every file set stored in the repository, once. Lists each non-Wings query service directly,
28
+ # once per name Hyrax registers for file sets (Hyrax::ModelRegistry.file_set_classes, which includes
29
+ # Hyrax.config.file_set_model). The composite query service converts the model it is given, so apps
30
+ # that store file sets under the legacy "FileSet" name get nothing back when asked through it.
31
+ def each_file_set
32
+ return enum_for(:each_file_set) unless block_given?
33
+
34
+ seen = Set.new
35
+ query_services.each do |service|
36
+ Hyrax::ModelRegistry.file_set_classes.each do |model|
37
+ service.find_all_of_model(model:).each { |file_set| yield file_set if seen.add?(file_set.id.to_s) }
38
+ end
39
+ end
40
+ end
41
+
42
+ # Yields [file_set, original file] for each file set that has an original. A file set whose original
43
+ # cannot be loaded (for example a dangling file record) goes to on_error and is skipped, so one bad
44
+ # record does not stop the run.
45
+ def each_original(limit: nil, progress: nil, on_error: nil)
46
+ return enum_for(:each_original, limit:, progress:, on_error:) unless block_given?
47
+
48
+ done = 0
49
+ each_file_set do |file_set|
50
+ break if limit && done >= limit
51
+
52
+ progress&.tick
53
+ original = original_for(file_set, on_error:)
54
+ next unless original
55
+
56
+ done += 1 if yield(file_set, original)
57
+ end
58
+ end
59
+
60
+ def original_for(file_set, on_error: nil)
61
+ Hyrax.custom_queries.find_original_file(file_set:)
62
+ rescue StandardError => e
63
+ on_error&.call(file_set, e)
64
+ nil
65
+ end
66
+
67
+ def query_services
68
+ service = Hyrax.query_service
69
+ services = service.respond_to?(:services) ? service.services : [service]
70
+ services.reject { |inner| defined?(Wings::Valkyrie::QueryService) && inner.is_a?(Wings::Valkyrie::QueryService) }
71
+ end
72
+ end
73
+ end
74
+
75
+ require 's3_migration/railtie' if defined?(Rails::Railtie)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Thin wrappers: the work lives in S3Migration::Tasks. See docs/migrating-files-to-s3.md.
4
+ namespace :s3_migration do
5
+ desc <<~DESC
6
+ Move original files from the plain disk storage adapter (disk:// ids) to the :repository_s3 adapter. By default this
7
+ enqueues jobs (one per file set; set S3_MIGRATION_QUEUE to use a dedicated queue). Each upload is
8
+ verified by SHA-256 read-back, and against the stored characterization checksum, before the identifier
9
+ is switched; the disk copy is never touched.
10
+ Env: DRY_RUN=true runs inline and only reports; INLINE=true runs inline for real; LIMIT=<n> files.
11
+ DESC
12
+ task migrate: :environment do
13
+ S3Migration::Tasks.run(:migrate)
14
+ end
15
+
16
+ desc <<~DESC
17
+ Read-only audit of originals already in S3: object readable, recorded size and stored checksum match.
18
+ Get zero problems before deleting anything from disk. Env: LIMIT=<n> files.
19
+ DESC
20
+ task verify: :environment do
21
+ S3Migration::Tasks.run(:verify)
22
+ end
23
+
24
+ desc 'Read-only report of file storage by file use and scheme (disk, shrine, ...).'
25
+ task report: :environment do
26
+ S3Migration::Tasks.run(:report)
27
+ end
28
+
29
+ desc <<~DESC
30
+ Move the disk copies of manifested files to <storage>/.quarantine after re-checking the database
31
+ and the S3 object against the manifest. Dry run unless APPLY=true. VERIFY_SHA=false skips the hash.
32
+ DESC
33
+ task quarantine: :environment do
34
+ S3Migration::Tasks.run(:quarantine)
35
+ end
36
+
37
+ desc 'Undo a migration from the manifest (restores the disk copy and id, removes the unused upload). ' \
38
+ 'Dry run unless APPLY=true.'
39
+ task rollback: :environment do
40
+ S3Migration::Tasks.run(:rollback)
41
+ end
42
+
43
+ desc 'Permanently delete the quarantined copies of manifested files older than MIN_AGE_DAYS (default 14). ' \
44
+ 'Dry run unless APPLY=true.'
45
+ task purge: :environment do
46
+ S3Migration::Tasks.run(:purge)
47
+ end
48
+ end